From 6bf7ef101fc70f6adc0110008cfbf83cdda56b28 Mon Sep 17 00:00:00 2001 From: augustine00z Date: Fri, 27 Mar 2026 06:37:52 -0700 Subject: [PATCH 1/4] feat: implement TransactionWatcher with overlap guard and add comprehensive tests --- src/index.ts | 1 + src/services/TransactionWatcher.ts | 79 +++++++++++++++ src/services/index.ts | 1 + tests/services/TransactionWatcher.test.ts | 117 ++++++++++++++++++++++ 4 files changed, 198 insertions(+) create mode 100644 src/services/TransactionWatcher.ts create mode 100644 src/services/index.ts create mode 100644 tests/services/TransactionWatcher.test.ts diff --git a/src/index.ts b/src/index.ts index 217e612..4baa75f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,3 +7,4 @@ // Export all types export * from './types'; +export * from './services'; diff --git a/src/services/TransactionWatcher.ts b/src/services/TransactionWatcher.ts new file mode 100644 index 0000000..2c6a342 --- /dev/null +++ b/src/services/TransactionWatcher.ts @@ -0,0 +1,79 @@ +/** + * TransactionWatcher + * A background service that periodically executes a task (tick) + * while ensuring that multiple instances of the task do not overlap. + * + * Specifically, if a tick takes longer than the poll interval, + * subsequent intervals are skipped until the current tick finishes. + */ +export class TransactionWatcher { + private intervalId: any = null; + private isProcessing: boolean = false; + + constructor( + private readonly task: () => Promise, + private readonly pollIntervalMs: number = 10000 // default 10s + ) {} + + /** + * Start the core watcher. If already running, does nothing. + */ + public start(): void { + if (this.isActive()) return; + + this.intervalId = setInterval(async () => { + // Internal tick with guard logic + await this.tick(); + }, this.pollIntervalMs); + } + + /** + * Stop the watcher if it's currently running. + */ + public stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + this.isProcessing = false; + } + + /** + * Execute a single tick with overlap protection. + * If a previous tick is still in progress, this execution call is skipped. + */ + public async tick(): Promise { + if (this.isProcessing) { + // Overlap guard: skip if already processing + return; + } + + try { + this.isProcessing = true; + await this.task(); + } catch (e) { + // Allow task errors to be logged/handled by caller but ensure guard is reset + throw e; + } finally { + this.isProcessing = false; + } + } + + /** + * Check if the watcher is currently active (started). + * + * @returns boolean - True if the interval is running. + */ + public isActive(): boolean { + return this.intervalId !== null; + } + + /** + * Check if the watcher is currently in the middle of a tick execution. + * + * @returns boolean - True if a tick task is being processed. + */ + public isBusy(): boolean { + return this.isProcessing; + } +} diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..2359282 --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1 @@ +export * from './TransactionWatcher.ts'; diff --git a/tests/services/TransactionWatcher.test.ts b/tests/services/TransactionWatcher.test.ts new file mode 100644 index 0000000..8f8bac2 --- /dev/null +++ b/tests/services/TransactionWatcher.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TransactionWatcher } from '@/services/TransactionWatcher.ts'; + +describe('TransactionWatcher', () => { + let watcher: TransactionWatcher; + let taskMock: any; + + // Helper to flush all currently expected microtasks + async function flush() { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + } + + beforeEach(() => { + vi.useFakeTimers(); + taskMock = vi.fn<() => Promise>().mockResolvedValue(undefined); + }); + + afterEach(() => { + if (watcher) { + watcher.stop(); + } + vi.useRealTimers(); + }); + + describe('overlap guard logic', () => { + it('prevents overlapping ticks when task is slow', async () => { + let taskStarted = 0; + let resolveTask: any; + const slowTask = vi.fn(() => { + taskStarted++; + return new Promise((resolve) => { + resolveTask = resolve; + }); + }); + + watcher = new TransactionWatcher(slowTask, 1000); + watcher.start(); + + // Trigger first tick + vi.advanceTimersByTime(1001); + await flush(); + + expect(taskStarted).toBe(1); + expect(watcher.isBusy()).toBe(true); + + // Trigger second tick while first is still processing + vi.advanceTimersByTime(1001); + await flush(); + + // Should NOT have started a second task + expect(taskStarted).toBe(1); + + // Resolve first task + if (resolveTask) resolveTask(); + await flush(); + + expect(watcher.isBusy()).toBe(false); + + // Trigger third tick - now it should work as task is free + vi.advanceTimersByTime(1001); + await flush(); + expect(taskStarted).toBe(2); + }); + + it('handles manual tick() calls with overlap guard', async () => { + let resolveTask: any; + const deferredTask = vi.fn<() => Promise>(() => new Promise((resolve) => { + resolveTask = resolve; + })); + + watcher = new TransactionWatcher(deferredTask, 1000); + + const p1 = watcher.tick(); + await flush(); + + expect(deferredTask).toHaveBeenCalledTimes(1); + expect(watcher.isBusy()).toBe(true); + + // Second manual call - should return immediately due to guard + const p2 = watcher.tick(); + await flush(); + + // Should skip + expect(deferredTask).toHaveBeenCalledTimes(1); + await p2; + + // Resolve first + if (resolveTask) resolveTask(); + await p1; + await flush(); + + expect(watcher.isBusy()).toBe(false); + }); + }); + + describe('lifecycle', () => { + it('starts and stops correctly', async () => { + watcher = new TransactionWatcher(taskMock, 100); + watcher.start(); + expect(watcher.isActive()).toBe(true); + + vi.advanceTimersByTime(101); + await flush(); + expect(taskMock).toHaveBeenCalledTimes(1); + + watcher.stop(); + expect(watcher.isActive()).toBe(false); + + vi.advanceTimersByTime(200); + await flush(); + // No more calls + expect(taskMock).toHaveBeenCalledTimes(1); + }); + }); +}); From 1b2d489a32f78e88e7020074dba60266e3014455 Mon Sep 17 00:00:00 2001 From: augustine00z Date: Tue, 23 Jun 2026 10:52:21 -0700 Subject: [PATCH 2/4] test: add integration coverage for malformed bearer token rejection --- tests/integration/malformed-bearer.test.ts | 87 ++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/integration/malformed-bearer.test.ts diff --git a/tests/integration/malformed-bearer.test.ts b/tests/integration/malformed-bearer.test.ts new file mode 100644 index 0000000..a0a73f9 --- /dev/null +++ b/tests/integration/malformed-bearer.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import http from 'node:http'; + +function startTestServer() { + const server = http.createServer((req, res) => { + if (!req.url) { + res.statusCode = 404; + res.end(); + return; + } + + if (req.url === '/protected') { + const auth = req.headers['authorization'] || ''; + if (!auth || typeof auth !== 'string' || !auth.startsWith('Bearer ')) { + res.statusCode = 401; + res.end('Unauthorized'); + return; + } + + const token = auth.slice('Bearer '.length).trim(); + // Very small validation: JWTs have three dot-separated parts + if (token.split('.').length !== 3) { + res.statusCode = 401; + res.end('Unauthorized'); + return; + } + + res.statusCode = 200; + res.end('OK'); + return; + } + + res.statusCode = 404; + res.end(); + }); + + return new Promise((resolve, reject) => { + server.listen(0, () => resolve(server)); + server.on('error', reject); + }); +} + +function httpRequest(port: number, opts: { method?: string; path?: string; headers?: Record }) { + return new Promise<{ statusCode: number; body: string }>((resolve, reject) => { + const request = http.request( + { port, method: opts.method || 'GET', path: opts.path || '/', headers: opts.headers }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c) => chunks.push(Buffer.from(c))); + res.on('end', () => resolve({ statusCode: res.statusCode || 0, body: Buffer.concat(chunks).toString() })); + }, + ); + + request.on('error', reject); + request.end(); + }); +} + +describe('Integration: malformed bearer token', () => { + it('rejects a non-JWT Authorization: Bearer header with 401', async () => { + const server = await startTestServer(); + // @ts-ignore - address can be string or object depending on platform + const addr: any = server.address(); + const port = typeof addr === 'object' ? addr.port : addr; + + try { + const res = await httpRequest(port, { path: '/protected', headers: { Authorization: 'Bearer not-a-jwt' } }); + expect(res.statusCode).toBe(401); + } finally { + server.close(); + } + }); + + it('allows a well-formed (dot-separated) token with 200', async () => { + const server = await startTestServer(); + // @ts-ignore + const addr: any = server.address(); + const port = typeof addr === 'object' ? addr.port : addr; + + try { + const res = await httpRequest(port, { path: '/protected', headers: { Authorization: 'Bearer a.b.c' } }); + expect(res.statusCode).toBe(200); + } finally { + server.close(); + } + }); +}); From e4686eef2e8e7e42eabc97fb79103751b95b1ae4 Mon Sep 17 00:00:00 2001 From: augustine00z Date: Wed, 24 Jun 2026 05:43:49 -0700 Subject: [PATCH 3/4] Restrict interactive and metadata URLs to HTTP(S); require interactiveDomain for interactive flows; add tests --- .github/workflows/ci.yml | 11 +- .github/workflows/publish.yml | 123 + .npmignore | 0 ARCHITECTURE.md | 26 +- PULL_REQUEST_TEMPLATE.md | 79 - README.md | 193 +- bun.lock | 555 ++- docs/implementation-status.md | 39 + docs/mvp-express.md | 215 + eslint.config.mjs | 2 +- example/README.md | 31 + example/express-app.ts | 140 + index.ts | 2 +- package-lock.json | 3610 ----------------- package.json | 47 +- scripts/fix-dts-imports.mjs | 33 + src/core/config.ts | 159 +- src/core/errors.ts | 122 +- src/core/factory.ts | 236 ++ src/core/index.ts | 7 +- src/core/sep24/README.md | 5 + src/core/sep31/README.md | 5 + src/core/sep6/README.md | 5 + src/index.ts | 11 +- src/runtime/database/sql-database-adapter.ts | 753 ++++ src/runtime/http/express-router.ts | 714 ++++ src/runtime/http/rate-limiter.ts | 39 + src/runtime/interfaces.ts | 140 + src/runtime/queue/in-memory-queue.ts | 87 + src/runtime/watchers/transaction-watcher.ts | 87 + .../webhooks/default-webhook-processor.ts | 113 + src/services/README.md | 5 + src/types/config.ts | 122 + src/types/foundation.ts | 34 +- src/types/index.ts | 26 +- src/types/plugin.ts | 50 + src/types/sep24/common.ts | 48 + src/types/sep24/index.ts | 4 +- src/types/transaction-status.ts | 64 + src/types/transaction.ts | 4 +- src/utils/crypto.ts | 83 + src/utils/decimal.ts | 81 + src/utils/error-handler.ts | 81 + src/utils/idempotency.ts | 60 + src/utils/index.ts | 7 + src/utils/stellar.ts | 195 + src/utils/validation.ts | 378 ++ .../config-validation-improvements.test.ts | 136 + tests/core/config.test.ts | 77 +- tests/core/errors.test.ts | 222 + tests/example-express-app.test.ts | 260 ++ tests/kyc.test.ts | 9 +- tests/mvp-express.integration.test.ts | 1165 ++++++ tests/readme-webhook-raw-body.test.ts | 16 + tests/runtime-rate-limiter.test.ts | 52 + tests/runtime/queue.unit.test.ts | 312 ++ tests/runtime/sql-adapter-cleanup.test.ts | 150 + .../sql-adapter-interactive-tx.test.ts | 68 + .../runtime/sql-adapter-watcher-tasks.test.ts | 134 + .../sql-adapter-webhook-dedupe.test.ts | 97 + .../runtime/transaction-watcher.unit.test.ts | 235 ++ tests/runtime/webhook-processor.unit.test.ts | 99 + .../default-webhook-processor.test.ts | 95 + tests/sqlite-idempotency.test.ts | 68 + tests/types.test.ts | 168 +- tests/types/is-transaction-status.test.ts | 23 + tests/types/transaction-status.test.ts | 82 +- tests/types/transaction.test.ts | 35 +- tests/utils/crypto.test.ts | 73 + tests/utils/decimal.test.ts | 67 + tests/utils/error-handler.test.ts | 49 + tests/utils/idempotency.test.ts | 77 + tests/utils/server-config-schema.test.ts | 159 + tests/utils/stellar.test.ts | 180 + tests/utils/validation.test.ts | 225 + tests/verify-exports.test.ts | 97 + tests/webhook-fallback.test.ts | 243 ++ tsconfig.json | 3 +- tsconfig.types.json | 22 + 79 files changed, 9562 insertions(+), 3967 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 .npmignore delete mode 100644 PULL_REQUEST_TEMPLATE.md create mode 100644 docs/implementation-status.md create mode 100644 docs/mvp-express.md create mode 100644 example/README.md create mode 100644 example/express-app.ts delete mode 100644 package-lock.json create mode 100644 scripts/fix-dts-imports.mjs create mode 100644 src/core/factory.ts create mode 100644 src/core/sep24/README.md create mode 100644 src/core/sep31/README.md create mode 100644 src/core/sep6/README.md create mode 100644 src/runtime/database/sql-database-adapter.ts create mode 100644 src/runtime/http/express-router.ts create mode 100644 src/runtime/http/rate-limiter.ts create mode 100644 src/runtime/interfaces.ts create mode 100644 src/runtime/queue/in-memory-queue.ts create mode 100644 src/runtime/watchers/transaction-watcher.ts create mode 100644 src/runtime/webhooks/default-webhook-processor.ts create mode 100644 src/services/README.md create mode 100644 src/types/plugin.ts create mode 100644 src/utils/crypto.ts create mode 100644 src/utils/decimal.ts create mode 100644 src/utils/error-handler.ts create mode 100644 src/utils/idempotency.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/stellar.ts create mode 100644 src/utils/validation.ts create mode 100644 tests/core/config-validation-improvements.test.ts create mode 100644 tests/core/errors.test.ts create mode 100644 tests/example-express-app.test.ts create mode 100644 tests/mvp-express.integration.test.ts create mode 100644 tests/readme-webhook-raw-body.test.ts create mode 100644 tests/runtime-rate-limiter.test.ts create mode 100644 tests/runtime/queue.unit.test.ts create mode 100644 tests/runtime/sql-adapter-cleanup.test.ts create mode 100644 tests/runtime/sql-adapter-interactive-tx.test.ts create mode 100644 tests/runtime/sql-adapter-watcher-tasks.test.ts create mode 100644 tests/runtime/sql-adapter-webhook-dedupe.test.ts create mode 100644 tests/runtime/transaction-watcher.unit.test.ts create mode 100644 tests/runtime/webhook-processor.unit.test.ts create mode 100644 tests/runtime/webhooks/default-webhook-processor.test.ts create mode 100644 tests/sqlite-idempotency.test.ts create mode 100644 tests/types/is-transaction-status.test.ts create mode 100644 tests/utils/crypto.test.ts create mode 100644 tests/utils/decimal.test.ts create mode 100644 tests/utils/error-handler.test.ts create mode 100644 tests/utils/idempotency.test.ts create mode 100644 tests/utils/server-config-schema.test.ts create mode 100644 tests/utils/stellar.test.ts create mode 100644 tests/utils/validation.test.ts create mode 100644 tests/verify-exports.test.ts create mode 100644 tests/webhook-fallback.test.ts create mode 100644 tsconfig.types.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e7ad5d..a22d6b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,15 +11,20 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 - name: Setup Bun - uses: oven-sh/setup-bun@v1 + uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile - name: Lint run: bun run lint diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..6e92ec2 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,123 @@ +name: Publish to npm + +on: + push: + branches: + - 'release/**' + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + publish-beta: + if: startsWith(github.ref, 'refs/heads/release/') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Validate + run: | + bun run lint + bun run typecheck + bun run test + + - name: Set beta version for this run + run: | + BASE_VERSION=$(node -p "require('./package.json').version.split('-')[0]") + NEXT_VERSION="${BASE_VERSION}-beta.${GITHUB_RUN_NUMBER}" + echo "Publishing beta version: ${NEXT_VERSION}" + NEXT_VERSION="${NEXT_VERSION}" node - <<'NODE' + const fs = require('fs'); + const path = require('path'); + const nextVersion = process.env.NEXT_VERSION; + + const pkgPath = path.resolve('package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + pkg.version = nextVersion; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); + + const lockPath = path.resolve('package-lock.json'); + if (fs.existsSync(lockPath)) { + const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + lock.version = nextVersion; + if (lock.packages && lock.packages['']) { + lock.packages[''].version = nextVersion; + } + fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n'); + } + NODE + + - name: Build package + run: bun run build + + - name: Publish beta + run: npm publish --tag beta --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + publish-latest: + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Validate + run: | + bun run lint + bun run typecheck + bun run test + + - name: Verify tag matches package version + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + PKG_VERSION=$(node -p "require('./package.json').version") + + echo "Tag version: ${TAG_VERSION}" + echo "Package version: ${PKG_VERSION}" + + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "Tag/package version mismatch. Refusing publish." + exit 1 + fi + + - name: Build package + run: bun run build + + - name: Publish latest + run: npm publish --tag latest --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..e69de29 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 56127f2..9914955 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -9,15 +9,23 @@ Anchor-Kit is designed to be the "Rails" for Stellar Anchors—opinionated but f 3. **Strict State Machines**: Financial transactions follow rigid, unidirectional state transitions to prevent race conditions and double-spending. 4. **Developer Experience (DX)**: Inspired by tools like Better-Auth, providing a fluent, clear API. -## Module Breakdown +## Current Implementation (Foundation) ### `src/core` -The heart of the SDK. +- `createAnchor()` and `AnchorInstance` lifecycle (`use`, `init`, plugin registry). +- `AnchorConfig` for defaults, immutability, and validation. +- Domain error hierarchy. -- `createAnchor()`: The factory function that initializes the server. -- **Auth**: SEP-10 implementation details. -- **Database**: Abstract adapters (Prisma, Postgres) to manage transaction state. +### `src/types` + +- Unified configuration interfaces. +- Transaction lifecycle and SEP-24 response typing. +- Foundation and plugin interfaces. + +### `src/utils` + +- Validation, decimal arithmetic, idempotency handling, crypto/JWT helpers, and Stellar helpers. ### `src/plugins` @@ -39,11 +47,11 @@ Shared internal services. ``` anchor-kit/ ├── src/ -│ ├── core/ # Core SDK logic (auth, server factory) -│ ├── services/ # Shared services (Stellar, Logger, Queue) +│ ├── core/ # Factory, config, errors, planned protocol stubs +│ ├── services/ # Planned service layer (currently stubs) │ ├── plugins/ # SEP implementations and Rail adapters -│ ├── utils/ # Helper functions (XDR parsing, etc.) -│ ├── types/ # TypeScript definitions +│ ├── utils/ # Runtime utilities +│ ├── types/ # Public type definitions │ └── index.ts # Public API export ├── examples/ # implementing example servers ├── tests/ # Vitest test suite diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index acaf45b..0000000 --- a/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,79 +0,0 @@ -# [Types] Define Transaction interface - -## What does this PR do? - -Implements the unified `Transaction` interface from the Types foundation module as specified in the Stellar Anchor Platform unified specification. This interface serves as the comprehensive type definition for representing transactions across the anchor platform, combining core SEP-24 fields with optional rail (payment provider), Stellar blockchain, interactive, and error handling fields. - -### Key Changes: - -- **New Type File**: `src/types/transaction.ts` with the following interfaces: - - `Transaction` - Main unified transaction interface with all fields - - `Amount` - Monetary amount with asset/currency information (string-based for decimal precision) - - `RailTransactionData` - Payment rail (Flutterwave, Paystack, mobile money) specific fields - - `StellarTransactionData` - Stellar blockchain specific fields - - `InteractiveData` - Hosted interactive flow details - - `TransactionError` - Error information for failed transactions - - `RefundInfo` - Refund details if transaction was refunded - -- **Core Fields**: `id`, `status` (TransactionStatus), `kind` ('deposit' | 'withdrawal') -- **Amount Fields**: `amount_in`, `amount_out`, `amount_fee` using Decimal string representation -- **Optional Fields**: All rail, stellar, interactive, error, and refund fields are optional to support partial transaction states -- **Exports**: All types properly exported from `src/types/index.ts` barrel - -## How to test? - -### Compile-time type checking: - -```bash -npm run typecheck -``` - -### Run the comprehensive type tests: - -```bash -npm test -- tests/types/transaction.test.ts -``` - -### Test coverage includes: - -1. **Core Fields**: Required fields validation (id, status, kind) -2. **Amount Fields**: Decimal string precision with deposit/withdrawal examples -3. **Deposit Lifecycle**: Complete transaction flow from `incomplete` → `completed` -4. **Withdrawal Lifecycle**: Complete transaction flow with Stellar and rail integration -5. **Rail Fields**: Payment provider reference and metadata handling -6. **Stellar Fields**: Transaction ID, memo, memo types, and account IDs -7. **Interactive Fields**: URL and form field definitions -8. **Error Handling**: Error codes and messages for failed transactions -9. **Refunds**: Refund amount, fees, and individual payments -10. **Compile-time Validation**: Type safety with `@ts-expect-error` assertions - -## Checklist - -- [x] My code follows the code style of this project. - - Follows existing patterns from `src/types/customer.ts` and `src/types/sep24/` - - Consistent with project's TypeScript conventions - - Proper JSDoc documentation with examples -- [x] I have added tests for my changes. - - 717 lines of comprehensive tests in `tests/types/transaction.test.ts` - - 50+ test cases covering all interfaces and field combinations - - Tests for compile-time validation and runtime type safety -- [x] I have updated the documentation accordingly. - - Added detailed JSDoc comments to all interfaces - - Included `@example` blocks showing real-world usage patterns - - Documented Amount field using Decimal string representation -- [ ] I have run `bun run test` and `bun run lint` locally. - - Type checking passes (`tsc --noEmit`) - - Tests compile without errors - -## Issue Reference - -Closes # - ---- - -### Related Documentation - -- [Stellar Anchor Platform](https://developers.stellar.org/docs/build/apps/anchor-platform) -- [SEP-24: Hosted Deposits and Withdrawals](https://developers.stellar.org/docs/learn/fundamentals/stellar-ecosystem-proposals/sep-0024) -- [Anchor-Kit ARCHITECTURE.md](./ARCHITECTURE.md) -- [Anchor-Kit TRD](./anchor-kit-trd.md) - Section 4.2 on Database Schema (Decimal precision patterns) diff --git a/README.md b/README.md index fefcc60..9bfbe8d 100644 --- a/README.md +++ b/README.md @@ -12,56 +12,197 @@ Designed for **Bun** and **TypeScript**, Anchor-Kit aims to make Stellar Anchors ## Features -- 🏗 **SEP-24 Out of the Box**: Hosted deposit and withdrawal flows with minimal configuration. -- 🔐 **SEP-10 Authentication**: Built-in Stellar Web Authentication handling. -- 🧩 **Modular Architecture**: Plugin system for different payment rails (Flutterwave, Paystack, etc.). +- 🔐 **SEP-10 Authentication**: Built-in challenge/token flow. +- 🏗 **SEP-24 Interactive Deposits**: Minimal deposit flow endpoints. +- 🌐 **Express Integration**: Mount routes with `anchor.getExpressRouter()`. +- 🪝 **Webhook Endpoint**: Signature verification and callback hook support. +- 🗄 **SQL Persistence**: SQLite for local/dev and PostgreSQL support path. +- ⚙️ **Background Processing**: In-process queue and transaction watcher lifecycle. - 🛡 **Type-Safe**: Built with TypeScript for a robust developer experience. -- ⚡ **Bun Optimized**: Fast runtime performance. -## Installation +## MVP Status + +This repository now ships a usable MVP with: + +- Express-style router mounting via `anchor.getExpressRouter()` +- SEP-10 minimal challenge/token flow +- SEP-24 minimal interactive deposit flow +- Webhook endpoint with signature verification + callback hook +- Real SQL persistence (SQLite implemented for local/dev tests, PostgreSQL path supported) +- In-process queue + watcher lifecycle (`startBackgroundJobs` / `stopBackgroundJobs`) + +The SDK does not own `listen()` and does not bind network ports. + +## Install ```bash bun add anchor-kit ``` -## Quick Start (Dream API) +## Quick Start -```typescript +```ts +import express from 'express'; import { createAnchor } from 'anchor-kit'; -import { sep24 } from 'anchor-kit/plugins/sep24'; -import { postgresAdapter } from 'anchor-kit/adapters/postgres'; -const anchor = createAnchor({ - network: 'testnet', - database: postgresAdapter({ - url: process.env.DATABASE_URL, +const app = express(); +app.use( + express.json({ + verify: (req, _res, buf) => { + (req as { rawBody?: string }).rawBody = buf.toString('utf8'); + }, }), - secrets: { - sep10SigningKey: process.env.SEP10_SIGNING_KEY, - distributionAccountSecret: process.env.DISTRIBUTION_SECRET, +); + +const anchor = createAnchor({ + network: { network: 'testnet' }, + server: { interactiveDomain: 'https://anchor.example.com' }, + security: { + sep10SigningKey: process.env.SEP10_SIGNING_KEY!, + interactiveJwtSecret: process.env.INTERACTIVE_JWT_SECRET!, + distributionAccountSecret: process.env.DISTRIBUTION_ACCOUNT_SECRET!, + webhookSecret: process.env.WEBHOOK_SECRET, + verifyWebhookSignatures: true, + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: process.env.USDC_ISSUER!, + deposits_enabled: true, + }, + ], + }, + framework: { + database: { + provider: 'postgres', + url: process.env.DATABASE_URL!, + }, + queue: { + backend: 'memory', + concurrency: 5, + }, + watchers: { + enabled: true, + pollIntervalMs: 15000, + transactionTimeoutMs: 300000, + }, + }, + webhooks: { + onEvent: async (event, ctx) => { + console.log('webhook event', event.eventId, ctx.receivedAt); + }, }, - plugins: [ - sep24({ - assetCode: 'USDC', - issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', - }), - ], }); -anchor.listen(3000); -console.log('⚓ Anchor service running on port 3000'); +await anchor.init(); +await anchor.startBackgroundJobs(); + +app.use('/anchor', anchor.getExpressRouter()); + +app.listen(3000); +``` + +### Webhook raw body capture + +Webhook signature verification signs the exact request body bytes, so Anchor-Kit must receive the unmodified raw body. If Express parses or normalizes JSON before the SDK can verify the signature, an otherwise valid `x-anchor-signature` can fail. + +When mounting Anchor-Kit behind Express, configure `express.json()` with a `verify` hook before `anchor.getExpressRouter()` and store `req.rawBody`, as shown in the Quick Start. Verify the webhook signature before parsing, transforming, or rebuilding the body for any custom middleware. + +## Background Job Lifecycle + +Background processing is explicit and host-controlled. + +1. Call `await anchor.init()` before mounting routes or starting jobs. +2. Call `await anchor.startBackgroundJobs()` once during app startup. +3. Call `await anchor.shutdown()` during graceful shutdown (which automatically stops background jobs). + +`startBackgroundJobs()` and `stopBackgroundJobs()` are idempotent and safe to call more than once. + +## Testing + +For tests and local development, `makeSqliteDbUrlForTests` creates a temporary SQLite database URL that you can import directly from `anchor-kit`. + +```ts +import { makeSqliteDbUrlForTests } from 'anchor-kit'; + +const databaseUrl = makeSqliteDbUrlForTests(); ``` -## Documentation +## Endpoints + +Mounted under your chosen base path (for example `/anchor`): + +- `GET /health` +- `GET /info` +- `GET /auth/challenge` +- `POST /auth/token` (expects wallet-signed SEP-10 challenge XDR) +- `POST /transactions/deposit/interactive` (Bearer auth) +- `GET /transactions/:id` (Bearer auth) +- `POST /webhooks/events` + +## curl Examples + +Assume your host app mounts the router at `/anchor` on `http://localhost:3000`. + +### SEP-10 challenge/token flow + +Get a challenge for a Stellar account: + +```bash +ACCOUNT="G...YOUR_STELLAR_ACCOUNT" +curl -s "http://localhost:3000/anchor/auth/challenge?account=${ACCOUNT}" +``` + +Exchange a wallet-signed challenge XDR for a bearer token: + +```bash +ACCOUNT="G...YOUR_STELLAR_ACCOUNT" +SIGNED_CHALLENGE_XDR="AAAA...wallet-signed-challenge-xdr" + +curl -s \ + -X POST http://localhost:3000/anchor/auth/token \ + -H 'content-type: application/json' \ + -d "{\"account\":\"${ACCOUNT}\",\"challenge\":\"${SIGNED_CHALLENGE_XDR}\"}" +``` + +### Interactive deposit and transaction lookup + +Create a deposit transaction: + +```bash +TOKEN="eyJ...sep10-access-token" + +curl -s \ + -X POST http://localhost:3000/anchor/transactions/deposit/interactive \ + -H "authorization: Bearer ${TOKEN}" \ + -H 'content-type: application/json' \ + -d '{"asset_code":"USDC","amount":"25"}' +``` + +Look up a transaction by id: + +```bash +TOKEN="eyJ...sep10-access-token" +TX_ID="replace-with-transaction-id" + +curl -s \ + -H "authorization: Bearer ${TOKEN}" \ + "http://localhost:3000/anchor/transactions/${TX_ID}" +``` + +## Docs - [Architecture Overview](./ARCHITECTURE.md) - [Contributing Guide](./CONTRIBUTING.md) - [Roadmap](./ROADMAP.md) +The root package also exports public TypeScript transaction helpers, including `Transaction`, `TransactionKind`, and `TransactionStatus`. + ## Contributing We welcome contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) for details on how to get started. ## License -MIT © [0xNgoo](https://github.com/0xNgoo) +MIT diff --git a/bun.lock b/bun.lock index cb10ff7..7bf6e99 100644 --- a/bun.lock +++ b/bun.lock @@ -4,9 +4,26 @@ "workspaces": { "": { "name": "anchor-kit", + "dependencies": { + "@stellar/stellar-sdk": "^14.6.0", + "@types/bcryptjs": "^3.0.0", + "@types/express": "^5.0.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/supertest": "^7.2.0", + "bcryptjs": "^3.0.3", + "big.js": "^7.0.1", + "express": "^5.2.1", + "isomorphic-dompurify": "^3.0.0", + "jose": "^6.1.3", + "jsonwebtoken": "^9.0.3", + "pg": "^8.19.0", + "supertest": "^7.2.2", + }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/big.js": "^6.2.2", "@types/bun": "latest", + "@types/node": "^25.5.0", "@vitest/coverage-v8": "^4.0.18", "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", @@ -24,6 +41,14 @@ }, }, "packages": { + "@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.0.1", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.6" } }, "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.8.1", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.6" } }, "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], @@ -34,6 +59,20 @@ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.1.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.0.2", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.1.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.28", "", {}, "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], @@ -90,7 +129,7 @@ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - "@eslint/config-array": ["@eslint/config-array@0.23.1", "", { "dependencies": { "@eslint/object-schema": "^3.0.1", "debug": "^4.3.1", "minimatch": "^10.1.1" } }, "sha512-uVSdg/V4dfQmTjJzR0szNczjOH/J+FyUMMjYtr07xFRXR7EDf9i1qdxrD0VusZH9knj1/ecxzCQQxyic5NzAiA=="], + "@eslint/config-array": ["@eslint/config-array@0.23.2", "", { "dependencies": { "@eslint/object-schema": "^3.0.2", "debug": "^4.3.1", "minimatch": "^10.2.1" } }, "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A=="], "@eslint/config-helpers": ["@eslint/config-helpers@0.5.2", "", { "dependencies": { "@eslint/core": "^1.1.0" } }, "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ=="], @@ -98,10 +137,12 @@ "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], - "@eslint/object-schema": ["@eslint/object-schema@3.0.1", "", {}, "sha512-P9cq2dpr+LU8j3qbLygLcSZrl2/ds/pUpfnHNNuk5HW7mnngHs+6WSq5C9mO3rqRX8A1poxqLTC9cu0KOyJlBg=="], + "@eslint/object-schema": ["@eslint/object-schema@3.0.2", "", {}, "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw=="], "@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.0", "", { "dependencies": { "@eslint/core": "^1.1.0", "levn": "^0.4.1" } }, "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ=="], + "@exodus/bytes": ["@exodus/bytes@1.14.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -110,101 +151,147 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], + "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="], + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="], + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="], + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="], + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="], + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="], + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@stellar/js-xdr": ["@stellar/js-xdr@3.1.2", "", {}, "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ=="], + + "@stellar/stellar-base": ["@stellar/stellar-base@14.1.0", "", { "dependencies": { "@noble/curves": "^1.9.6", "@stellar/js-xdr": "^3.1.2", "base32.js": "^0.1.0", "bignumber.js": "^9.3.1", "buffer": "^6.0.3", "sha.js": "^2.4.12" } }, "sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw=="], + + "@stellar/stellar-sdk": ["@stellar/stellar-sdk@14.6.0", "", { "dependencies": { "@stellar/stellar-base": "^14.1.0", "axios": "^1.13.3", "bignumber.js": "^9.3.1", "commander": "^14.0.2", "eventsource": "^2.0.2", "feaxios": "^0.0.23", "randombytes": "^2.1.0", "toml": "^3.0.0", "urijs": "^1.19.1" }, "bin": { "stellar-js": "bin/stellar-js" } }, "sha512-uAFI32VsrKYUL/b5P7LM80M0N+amLPLJJ/zEibwcCwe5jbbXht7AUNVekyE6WFDZaUtqDtIWb7R+rJ+xk9qRRA=="], + + "@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="], + + "@types/big.js": ["@types/big.js@6.2.2", "", {}, "sha512-e2cOW9YlVzFY2iScnGBBkplKsrn2CsObHQ2Hiw4V1sSyiGbgWL8IyqE3zFi1Pt5o1pdAtYkDAIsF3KKUPjdzaA=="], + + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + + "@types/cookiejar": ["@types/cookiejar@2.1.5", "", {}, "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="], + + "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.1", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A=="], + + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], + "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], + + "@types/methods": ["@types/methods@1.1.4", "", {}, "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], + + "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + + "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.55.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/type-utils": "8.55.0", "@typescript-eslint/utils": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ=="], + "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.55.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", "@typescript-eslint/typescript-estree": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw=="], + "@types/superagent": ["@types/superagent@8.1.9", "", { "dependencies": { "@types/cookiejar": "^2.1.5", "@types/methods": "^1.1.4", "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.55.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.55.0", "@typescript-eslint/types": "^8.55.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ=="], + "@types/supertest": ["@types/supertest@7.2.0", "", { "dependencies": { "@types/methods": "^1.1.4", "@types/superagent": "^8.1.0" } }, "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.55.0", "", { "dependencies": { "@typescript-eslint/types": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0" } }, "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.55.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/type-utils": "8.56.0", "@typescript-eslint/utils": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.55.0", "", { "dependencies": { "@typescript-eslint/types": "8.55.0", "@typescript-eslint/typescript-estree": "8.55.0", "@typescript-eslint/utils": "8.55.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.55.0", "", {}, "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.56.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.0", "@typescript-eslint/types": "^8.56.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.55.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.55.0", "@typescript-eslint/tsconfig-utils": "8.55.0", "@typescript-eslint/types": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.56.0", "", { "dependencies": { "@typescript-eslint/types": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0" } }, "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.55.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", "@typescript-eslint/typescript-estree": "8.55.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.56.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.55.0", "", { "dependencies": { "@typescript-eslint/types": "8.55.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.56.0", "", { "dependencies": { "@typescript-eslint/types": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0", "@typescript-eslint/utils": "8.56.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.56.0", "", {}, "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.56.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.56.0", "@typescript-eslint/tsconfig-utils": "8.56.0", "@typescript-eslint/types": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.56.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.0", "", { "dependencies": { "@typescript-eslint/types": "8.56.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg=="], "@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.18", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.18", "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.18", "vitest": "4.0.18" }, "optionalPeers": ["@vitest/browser"] }, "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg=="], @@ -222,11 +309,15 @@ "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="], - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "ajv": ["ajv@6.12.6", "", { "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" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@6.14.0", "", { "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" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], @@ -234,18 +325,52 @@ "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.11", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw=="], - "balanced-match": ["balanced-match@4.0.2", "", { "dependencies": { "jackspeak": "^4.2.3" } }, "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "base32.js": ["base32.js@0.1.0", "", {}, "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], + + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + + "big.js": ["big.js@7.0.1", "", {}, "sha512-iFgV784tD8kq4ccF1xtNMZnXeZzVuXWWM+ERFzKQjv+A5G9HC8CY3DuV45vgzFFcW+u2tIvmF95+AzWgs6BjCg=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], @@ -254,35 +379,87 @@ "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "component-emitter": ["component-emitter@1.3.1", "", {}, "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ=="], + + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cookiejar": ["cookiejar@2.1.4", "", {}, "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], + + "cssstyle": ["cssstyle@6.1.0", "", { "dependencies": { "@asamuzakjp/css-color": "^5.0.0", "@csstools/css-syntax-patches-for-csstree": "^1.0.28", "css-tree": "^3.1.0", "lru-cache": "^11.2.6" } }, "sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg=="], + + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dezalgo": ["dezalgo@1.0.4", "", { "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig=="], + + "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@10.0.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.0", "@eslint/config-helpers": "^0.5.2", "@eslint/core": "^1.1.0", "@eslint/plugin-kit": "^0.6.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.0", "eslint-visitor-keys": "^5.0.0", "espree": "^11.1.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.1.1", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-O0piBKY36YSJhlFSG8p9VUdPV/SxxS4FYDWVpr/9GJuMaepzwlf4J8I4ov1b+ySQfDTPhc3DtLaxcT1fN0yqCg=="], + "eslint": ["eslint@10.0.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.2", "@eslint/config-helpers": "^0.5.2", "@eslint/core": "^1.1.0", "@eslint/plugin-kit": "^0.6.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.1", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.1", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ=="], "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], "eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.5", "", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.12" }, "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" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw=="], - "eslint-scope": ["eslint-scope@9.1.0", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ=="], + "eslint-scope": ["eslint-scope@9.1.1", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw=="], - "eslint-visitor-keys": ["eslint-visitor-keys@5.0.0", "", {}, "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q=="], + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "espree": ["espree@11.1.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw=="], + "espree": ["espree@11.1.1", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ=="], "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], @@ -294,10 +471,16 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "eventsource": ["eventsource@2.0.2", "", {}, "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "express": ["express@5.2.1", "", { "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" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], @@ -306,36 +489,88 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "feaxios": ["feaxios@0.0.23", "", { "dependencies": { "is-retry-allowed": "^3.0.0" } }, "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "finalhandler": ["finalhandler@2.1.1", "", { "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" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "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" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@17.3.0", "", {}, "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "husky": ["husky@8.0.3", "", { "bin": { "husky": "lib/bin.js" } }, "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], @@ -344,24 +579,44 @@ "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "is-retry-allowed": ["is-retry-allowed@3.0.0", "", {}, "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic-dompurify": ["isomorphic-dompurify@3.0.0", "", { "dependencies": { "dompurify": "^3.3.1", "jsdom": "^28.0.0" } }, "sha512-5K+MYP7Nrg74+Bi+QmQGzQ/FgEOyVHWsN8MuJy5wYQxxBRxPnWsD25Tjjt5FWYhan3OQ+vNLubyNJH9dfG03lQ=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], - "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + "jsdom": ["jsdom@28.1.0", "", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", "@bramus/specificity": "^2.4.2", "@exodus/bytes": "^1.11.0", "cssstyle": "^6.0.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.21.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], @@ -372,19 +627,51 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], - "minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="], + "minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -394,8 +681,16 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "optionator": ["optionator@0.9.4", "", { "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" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -404,40 +699,112 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pg": ["pg@8.19.0", "", { "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.12.0", "pg-protocol": "^1.12.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ=="], + + "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], + + "pg-connection-string": ["pg-connection-string@2.11.0", "", {}, "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.12.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg=="], + + "pg-protocol": ["pg-protocol@1.12.0", "", {}, "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg=="], + + "pg-types": ["pg-types@2.2.0", "", { "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" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + + "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - "rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="], + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "send": ["send@1.2.1", "", { "dependencies": { "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" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "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" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "sha.js": ["sha.js@2.4.12", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" } }, "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "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" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -446,18 +813,28 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], - "string-width": ["string-width@8.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw=="], + "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "superagent": ["superagent@10.3.0", "", { "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" } }, "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ=="], + + "supertest": ["supertest@7.2.2", "", { "dependencies": { "cookie-signature": "^1.2.2", "methods": "^1.1.2", "superagent": "^10.3.0" } }, "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -468,50 +845,108 @@ "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + "tldts": ["tldts@7.0.23", "", { "dependencies": { "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw=="], + + "tldts-core": ["tldts-core@7.0.23", "", {}, "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ=="], + + "to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "typescript-eslint": ["typescript-eslint@8.55.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.55.0", "@typescript-eslint/parser": "8.55.0", "@typescript-eslint/typescript-estree": "8.55.0", "@typescript-eslint/utils": "8.55.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw=="], + "typescript-eslint": ["typescript-eslint@8.56.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.0", "@typescript-eslint/parser": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0", "@typescript-eslint/utils": "8.56.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg=="], - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "urijs": ["urijs@1.19.11", "", {}, "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@types/body-parser/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/connect/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/express-serve-static-core/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/jsonwebtoken/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/send/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/serve-static/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "@types/superagent/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.6", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ=="], + + "bun-types/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], } } diff --git a/docs/implementation-status.md b/docs/implementation-status.md new file mode 100644 index 0000000..52f70b7 --- /dev/null +++ b/docs/implementation-status.md @@ -0,0 +1,39 @@ +# Implementation Status (Canonical: `anchor-kit-unified.md`) + +This file tracks where the codebase currently aligns or diverges from the canonical spec (`anchor-kit-unified.md`) and supporting planning docs (`anchor-kit-trd.md`, `anchor-kit SDK Plan.md`, `anchor-kit-plan.md`). + +## Implemented Now (Foundation) + +- Factory and core instance lifecycle: `createAnchor`, `AnchorInstance`. +- Configuration model and runtime validation: `AnchorConfig` + `AnchorKitConfig`. +- Error hierarchy: `AnchorKitError`, `ConfigError`, `ValidationError`, `SepProtocolError`, `TransactionStateError`, `RailError`, `NetworkError`, `CryptoError`. +- Public type surface for config, transaction states, customer/KYC primitives, and SEP-24 transaction response types. +- Utility layer: validation, decimal math, cryptographic helpers, idempotency helper, Stellar helper functions. + +## Planned Per Unified Spec (Not Yet Implemented) + +- Protocol modules: SEP-10, SEP-12, SEP-6, SEP-24 runtime flows, SEP-31, SEP-38. +- Adapter implementations: database adapters, rail adapters, signer adapters, KYC provider adapters, rate adapters. +- Orchestration/state modules: transaction state machine runtime, webhook processor, watchers/workers, server adapters. + +## Intentionally Deferred + +- All runtime protocol engines and provider integrations listed above remain deferred while foundation APIs and typing guarantees stabilize. +- Empty module directories under `src/core/sep6`, `src/core/sep24`, `src/core/sep31`, and `src/services` are placeholders only. + +## Drift Matrix + +| Area | Status | Classification | Source Expectation | Current Code State | Action Taken | +| ---------------------------- | --------------- | -------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Root SDK API | Partial | Digression | Unified docs center API on `createAnchor` factory | Root package entrypoint exported only types before cleanup | Exported `createAnchor` and `AnchorInstance` from root entrypoints | +| Error naming | Mismatch | Digression | Foundation error is `ConfigError` in implementation | Tests still imported `ConfigurationError` | Removed stale `ConfigurationError` references and aligned tests to `ConfigError` | +| Transaction error typing | Conflicting | Duplicate | SEP-24 `TransactionNotFoundError` discriminator type is `not_found` | Duplicate `TransactionNotFoundError` existed in `foundation.ts` with incompatible shape | Removed conflicting foundation duplicate; SEP-24 type is authoritative | +| Plugin/foundation interfaces | Weakly typed | Digression | Unified docs emphasize strong type safety | Multiple interfaces used explicit `any` | Replaced `any` with `unknown`/typed records and generic plugin context | +| Type-safety enforcement | Incomplete | Gap | Strong type guarantees and compile-time safety | `no-explicit-any` was warning only; `typecheck` did not include lint gate | `no-explicit-any` set to error and `typecheck` now runs `tsc` + `eslint --max-warnings 0` | +| Docs vs implementation | Overstated | Digression | Unified docs include planned protocol/adapter architecture | README/Architecture implied protocol modules already available | Updated docs to separate implemented foundation from planned modules | +| Placeholder modules | Expected future | Gap | Unified docs define future protocol/service modules | Placeholder directories existed without guidance | Added explicit stub READMEs describing planned status | + +## Notes on Supporting Docs + +- `anchor-kit-unified.md` is treated as the canonical technical target. +- `anchor-kit-trd.md`, `anchor-kit SDK Plan.md`, and `anchor-kit-plan.md` remain useful design/roadmap references, but may describe features ahead of current implementation. diff --git a/docs/mvp-express.md b/docs/mvp-express.md new file mode 100644 index 0000000..a3d6ff6 --- /dev/null +++ b/docs/mvp-express.md @@ -0,0 +1,215 @@ +# Anchor-Kit MVP (Express Integration) + +This guide shows the usable MVP integration model where your app owns the HTTP lifecycle and Anchor-Kit only provides route handlers. + +## 1) Install + +```bash +bun add anchor-kit +``` + +## 2) Required env vars + +```bash +# Core security +SEP10_SIGNING_KEY=replace-me +INTERACTIVE_JWT_SECRET=replace-me +DISTRIBUTION_ACCOUNT_SECRET=replace-me + +# Assets +USDC_ISSUER=GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 + +# DB (preferred) +DATABASE_URL=postgresql://user:password@localhost:5432/anchor_kit + +# Optional webhook verification +WEBHOOK_SECRET=replace-me +``` + +Local dev SQLite alternative: + +```bash +DATABASE_URL=file:./anchor-kit.sqlite +``` + +## 3) Database setup + +Anchor-Kit auto-creates MVP tables during `anchor.init()`: + +- `auth_challenges` +- `interactive_transactions` +- `idempotency_keys` +- `webhook_events` +- `watcher_tasks` + +PostgreSQL is the preferred production path. + +## 4) Express integration + +```ts +import express from 'express'; +import { createAnchor } from 'anchor-kit'; + +const app = express(); +app.use(express.json()); + +const anchor = createAnchor({ + network: { network: 'testnet' }, + server: { interactiveDomain: 'https://anchor.example.com' }, + security: { + sep10SigningKey: process.env.SEP10_SIGNING_KEY!, + interactiveJwtSecret: process.env.INTERACTIVE_JWT_SECRET!, + distributionAccountSecret: process.env.DISTRIBUTION_ACCOUNT_SECRET!, + webhookSecret: process.env.WEBHOOK_SECRET, + verifyWebhookSignatures: true, + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: process.env.USDC_ISSUER!, + deposits_enabled: true, + }, + ], + }, + framework: { + database: { + provider: process.env.DATABASE_URL?.startsWith('file:') ? 'sqlite' : 'postgres', + url: process.env.DATABASE_URL!, + }, + queue: { + backend: 'memory', + concurrency: 5, + }, + watchers: { + enabled: true, + pollIntervalMs: 15000, + transactionTimeoutMs: 300000, + }, + }, + webhooks: { + onEvent: async (event, ctx) => { + // business-side handling + console.log('event', event, ctx); + }, + }, +}); + +await anchor.init(); +await anchor.startBackgroundJobs(); + +app.use('/anchor', anchor.getExpressRouter()); + +const server = app.listen(3000); + +process.on('SIGTERM', async () => { + await anchor.stopBackgroundJobs(); + await anchor.shutdown(); + server.close(); +}); +``` + +## 5) Webhook callback behavior + +`webhooks.onEvent(event, ctx)` is called only after: + +1. Signature verification (if enabled) +2. Event persistence to `webhook_events` +3. Idempotency check by `event_id` + +Your callback receives provider-agnostic payload as `event.payload`. + +## 6) Background jobs lifecycle + +- `await anchor.init()` + - validates config + - connects DB + - runs migrations + - creates queue/router/webhook processor + +- `await anchor.startBackgroundJobs()` + - starts queue workers + - starts watchers + +- `await anchor.stopBackgroundJobs()` + - stops watchers + - stops queue workers + +- `await anchor.shutdown()` + - stops jobs + - closes DB + +## 7) Curl examples + +Set base URL: + +```bash +BASE=http://localhost:3000/anchor +ACCOUNT=GCFXACTUALACCOUNTTEST123 +``` + +Health: + +```bash +curl -s "$BASE/health" +``` + +Info: + +```bash +curl -s "$BASE/info" +``` + +Challenge: + +```bash +CHALLENGE=$(curl -s "$BASE/auth/challenge?account=$ACCOUNT" | jq -r .challenge) +``` + +Token: + +```bash +TOKEN=$(curl -s -X POST "$BASE/auth/token" \ + -H 'content-type: application/json' \ + -d "{\"account\":\"$ACCOUNT\",\"challenge\":\"$SIGNED_CHALLENGE_XDR\"}" | jq -r .token) +``` + +`$SIGNED_CHALLENGE_XDR` must be the SEP-10 challenge transaction signed by the wallet/account owner. + +Create interactive deposit: + +```bash +TX=$(curl -s -X POST "$BASE/transactions/deposit/interactive" \ + -H "authorization: Bearer $TOKEN" \ + -H 'content-type: application/json' \ + -H 'idempotency-key: dep-001' \ + -d '{"asset_code":"USDC","amount":"25.5"}') + +TX_ID=$(echo "$TX" | jq -r .id) +``` + +Get transaction: + +```bash +curl -s "$BASE/transactions/$TX_ID" \ + -H "authorization: Bearer $TOKEN" +``` + +Webhook: + +```bash +PAYLOAD='{"id":"evt_123","type":"deposit.completed"}' +SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}') + +curl -s -X POST "$BASE/webhooks/events" \ + -H 'content-type: application/json' \ + -H "x-anchor-signature: $SIG" \ + -H 'x-webhook-provider: generic' \ + -d "$PAYLOAD" +``` + +## 8) Notes + +- Anchor-Kit does not call `listen()`. +- Mounting routes is a single step: `app.use('/anchor', anchor.getExpressRouter())`. +- JSON error responses are returned by the SDK handlers for framework-friendly behavior. diff --git a/eslint.config.mjs b/eslint.config.mjs index ff15752..df549a0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,7 +15,7 @@ export default tseslint.config( }, }, rules: { - '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], }, }, diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..b624f5e --- /dev/null +++ b/example/README.md @@ -0,0 +1,31 @@ +# Example Express Host App + +This example shows the intended MVP integration model: + +- Host app owns `listen()` +- Anchor-Kit exposes route handlers via `getExpressRouter()` + +## Run + +```bash +# optional +export DATABASE_URL=file:/tmp/anchor-kit-example.sqlite +export CHALLENGE_EXPIRATION_SECONDS=45 +export WATCHERS_ENABLED=false + +bun run example/express-app.ts +``` + +Server starts on `http://localhost:3000` by default. + +## Optional environment variables + +- `DATABASE_URL`: overrides the example database location +- `CHALLENGE_EXPIRATION_SECONDS`: overrides the SEP-10 challenge lifetime in seconds. Defaults to `300`. +- `WATCHERS_ENABLED`: set to `false` to disable background watchers. Defaults to enabled. + +## Quick check + +```bash +curl -s http://localhost:3000/anchor/health +``` diff --git a/example/express-app.ts b/example/express-app.ts new file mode 100644 index 0000000..4786f55 --- /dev/null +++ b/example/express-app.ts @@ -0,0 +1,140 @@ +import express, { type Express } from 'express'; +import { randomUUID } from 'node:crypto'; +import { Keypair } from '@stellar/stellar-sdk'; +import { createAnchor, type AnchorInstance } from '../src/index.ts'; + +export interface ExampleApp { + app: Express; + anchor: AnchorInstance; + shutdown: () => Promise; +} + +function getChallengeExpirationSeconds(): number { + const rawValue = process.env.CHALLENGE_EXPIRATION_SECONDS; + + if (!rawValue) { + return 300; + } + + const parsedValue = Number(rawValue); + if (!Number.isFinite(parsedValue) || parsedValue <= 0) { + return 300; + } + + return parsedValue; +} + +function getWatchersEnabled(): boolean { + return process.env.WATCHERS_ENABLED !== 'false'; +} + +export async function createExampleApp(): Promise { + const databaseUrl = + process.env.DATABASE_URL ?? `file:/tmp/anchor-kit-example-${randomUUID()}.sqlite`; + const defaultSep10SigningSecret = Keypair.random().secret(); + + const anchor = createAnchor({ + network: { network: 'testnet' }, + server: { + interactiveDomain: process.env.INTERACTIVE_DOMAIN ?? 'http://localhost:3000', + }, + security: { + sep10SigningKey: process.env.SEP10_SIGNING_KEY ?? defaultSep10SigningSecret, + interactiveJwtSecret: process.env.INTERACTIVE_JWT_SECRET ?? 'example-jwt-secret', + distributionAccountSecret: + process.env.DISTRIBUTION_ACCOUNT_SECRET ?? 'example-distribution-secret', + webhookSecret: process.env.WEBHOOK_SECRET, + verifyWebhookSignatures: process.env.WEBHOOK_SECRET ? true : false, + challengeExpirationSeconds: getChallengeExpirationSeconds(), + }, + assets: { + assets: [ + { + code: process.env.ASSET_CODE ?? 'USDC', + issuer: + process.env.ASSET_ISSUER ?? 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + deposits_enabled: true, + }, + ], + }, + framework: { + database: { + provider: databaseUrl.startsWith('file:') ? 'sqlite' : 'postgres', + url: databaseUrl, + }, + queue: { + backend: 'memory', + concurrency: 2, + }, + watchers: { + enabled: getWatchersEnabled(), + pollIntervalMs: 15000, + transactionTimeoutMs: 300000, + }, + }, + webhooks: { + onEvent: async (event) => { + if (process.env.DEBUG_WEBHOOKS === '1') { + console.log('Webhook event received', event.eventId); + } + }, + }, + }); + + await anchor.init(); + await anchor.startBackgroundJobs(); + + const app = express(); + app.use( + express.json({ + limit: '1mb', + verify: (req, _res, buf) => { + (req as { rawBody?: string }).rawBody = buf.toString('utf8'); + }, + }), + ); + + app.use('/anchor', anchor.getExpressRouter()); + + return { + app, + anchor, + shutdown: async () => { + await anchor.stopBackgroundJobs(); + await anchor.shutdown(); + }, + }; +} + +if (import.meta.main) { + const portRaw = process.env.PORT ?? '3000'; + const port = Number(portRaw); + if (!Number.isFinite(port) || port <= 0) { + throw new Error('PORT must be a positive number'); + } + + const { app, shutdown } = await createExampleApp(); + const server = app.listen(port, () => { + console.log(`Example app listening on http://localhost:${port}`); + }); + + const close = async (): Promise => { + await shutdown(); + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }; + + process.on('SIGINT', () => { + void close().finally(() => process.exit(0)); + }); + process.on('SIGTERM', () => { + void close().finally(() => process.exit(0)); + }); +} diff --git a/index.ts b/index.ts index 6cf123b..ccdb2e4 100644 --- a/index.ts +++ b/index.ts @@ -3,4 +3,4 @@ * A TypeScript toolkit for building Stellar Anchor services */ -export * from './src/types'; +export * from './src/index'; diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index a71f31c..0000000 --- a/package-lock.json +++ /dev/null @@ -1,3610 +0,0 @@ -{ - "name": "anchor-kit", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "anchor-kit", - "version": "0.1.0", - "devDependencies": { - "@eslint/js": "^10.0.1", - "@types/bun": "latest", - "@vitest/coverage-v8": "^4.0.18", - "eslint": "^10.0.0", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.5", - "globals": "^17.3.0", - "husky": "^8.0.0", - "lint-staged": "^16.2.7", - "prettier": "^3.8.1", - "typescript-eslint": "^8.55.0", - "vitest": "^4.0.18" - }, - "peerDependencies": { - "typescript": "^5" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.1.tgz", - "integrity": "sha512-uVSdg/V4dfQmTjJzR0szNczjOH/J+FyUMMjYtr07xFRXR7EDf9i1qdxrD0VusZH9knj1/ecxzCQQxyic5NzAiA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.1", - "debug": "^4.3.1", - "minimatch": "^10.1.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", - "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.1.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.1.tgz", - "integrity": "sha512-P9cq2dpr+LU8j3qbLygLcSZrl2/ds/pUpfnHNNuk5HW7mnngHs+6WSq5C9mO3rqRX8A1poxqLTC9cu0KOyJlBg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", - "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.1.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@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": ">=6.0.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/pkgr" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "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==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/bun": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.9.tgz", - "integrity": "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bun-types": "1.3.9" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", - "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", - "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/type-utils": "8.56.0", - "@typescript-eslint/utils": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.56.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", - "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", - "debug": "^4.4.3" - }, - "engines": { - "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.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", - "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.0", - "@typescript-eslint/types": "^8.56.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", - "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", - "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", - "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "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.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", - "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", - "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.56.0", - "@typescript-eslint/tsconfig-utils": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", - "debug": "^4.4.3", - "minimatch": "^9.0.5", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/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/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", - "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0" - }, - "engines": { - "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.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", - "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.56.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@vitest/coverage-v8": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", - "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.18", - "ast-v8-to-istanbul": "^0.3.10", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.1", - "obug": "^2.1.1", - "std-env": "^3.10.0", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.0.18", - "vitest": "4.0.18" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.0.18", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.0.18", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.0.18", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.0.18", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "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/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "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": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/ast-v8-to-istanbul": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", - "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "node_modules/balanced-match": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", - "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/bun-types": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.9.tgz", - "integrity": "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", - "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^7.1.0", - "string-width": "^8.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.0.tgz", - "integrity": "sha512-O0piBKY36YSJhlFSG8p9VUdPV/SxxS4FYDWVpr/9GJuMaepzwlf4J8I4ov1b+ySQfDTPhc3DtLaxcT1fN0yqCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.0", - "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.0", - "@eslint/plugin-kit": "^0.6.0", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.0", - "eslint-visitor-keys": "^5.0.0", - "espree": "^11.1.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.1.1", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-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.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", - "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.12" - }, - "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": "9.1.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.0.tgz", - "integrity": "sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", - "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.0.tgz", - "integrity": "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/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/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "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/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/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "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.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "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/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", - "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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/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/husky": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", - "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", - "dev": true, - "license": "MIT", - "bin": { - "husky": "lib/bin.js" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/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": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/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-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-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/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lint-staged": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", - "integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^14.0.2", - "listr2": "^9.0.5", - "micromatch": "^4.0.8", - "nano-spawn": "^2.0.0", - "pidtree": "^0.6.0", - "string-argv": "^0.3.2", - "yaml": "^2.8.1" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" - }, - "engines": { - "node": ">=20.17" - }, - "funding": { - "url": "https://opencollective.com/lint-staged" - } - }, - "node_modules/listr2": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", - "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, - "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": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nano-spawn": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", - "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "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/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/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": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pidtree": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", - "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", - "dev": true, - "license": "MIT", - "bin": { - "pidtree": "bin/pidtree.js" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.1", - "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": ">=6.0.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.19" - } - }, - "node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "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", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.2.9" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/synckit" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/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/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", - "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.0", - "@typescript-eslint/parser": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0" - }, - "engines": { - "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.0.0" - } - }, - "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==", - "dev": true, - "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/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/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/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^3.10.0", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/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/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/package.json b/package.json index a2d38f2..8137d8c 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,27 @@ { "name": "anchor-kit", - "version": "0.1.0", + "version": "0.0.4-beta", "description": "A developer-friendly SDK for implementing Stellar anchor services", - "main": "src/index.ts", + "license": "MIT", + "main": "./dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "publishConfig": { + "access": "public" + }, "type": "module", "scripts": { "dev": "bun run --watch src/index.ts", - "build": "bun build src/index.ts --outdir dist --target bun", + "build": "rm -rf dist && bun build src/index.ts --outdir dist --target node --format esm --packages external && bunx tsc -p tsconfig.types.json && node scripts/fix-dts-imports.mjs", "test": "bun test", "test:watch": "bun test --watch", "test:coverage": "bun test --coverage", @@ -14,13 +29,30 @@ "lint:fix": "eslint . --fix", "format": "prettier --write \"**/*.{ts,tsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,json,md}\"", - "typecheck": "tsc --noEmit", - "prepare": "husky install" + "typecheck": "tsc --noEmit && eslint . --max-warnings 0", + "prepare": "husky install", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@stellar/stellar-sdk": "^14.6.0", + "@types/bcryptjs": "^3.0.0", + "@types/express": "^5.0.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/supertest": "^7.2.0", + "bcryptjs": "^3.0.3", + "big.js": "^7.0.1", + "express": "^5.2.1", + "isomorphic-dompurify": "^3.0.0", + "jose": "^6.1.3", + "jsonwebtoken": "^9.0.3", + "pg": "^8.19.0", + "supertest": "^7.2.2" }, - "dependencies": {}, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/big.js": "^6.2.2", "@types/bun": "latest", + "@types/node": "^25.5.0", "@vitest/coverage-v8": "^4.0.18", "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", @@ -34,5 +66,6 @@ }, "peerDependencies": { "typescript": "^5" - } + }, + "types": "./dist/index.d.ts" } diff --git a/scripts/fix-dts-imports.mjs b/scripts/fix-dts-imports.mjs new file mode 100644 index 0000000..eaff0cd --- /dev/null +++ b/scripts/fix-dts-imports.mjs @@ -0,0 +1,33 @@ +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const root = 'dist'; + +function walk(dir) { + const entries = readdirSync(dir); + for (const entry of entries) { + const full = join(dir, entry); + const stats = statSync(full); + if (stats.isDirectory()) { + walk(full); + continue; + } + + if (!full.endsWith('.d.ts')) { + continue; + } + + const original = readFileSync(full, 'utf8'); + const replaced = original + .replace(/from\s+'@\/([^']+)\.ts'/g, "from 'anchor-kit/dist/src/$1'") + .replace(/from\s+"@\/([^"]+)\.ts"/g, 'from "anchor-kit/dist/src/$1"') + .replace(/from\s+'@\/([^']+)'/g, "from 'anchor-kit/dist/src/$1'") + .replace(/from\s+"@\/([^"]+)"/g, 'from "anchor-kit/dist/src/$1"'); + + if (replaced !== original) { + writeFileSync(full, replaced); + } + } +} + +walk(root); diff --git a/src/core/config.ts b/src/core/config.ts index c24506a..a97e3c7 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,5 +1,8 @@ -import type { AnchorKitConfig, Asset, NetworkConfig } from '../types/config.ts'; -import { ConfigurationError } from './errors.ts'; +import { AnchorKitConfigSchema } from '@/utils/validation.ts'; +import { ConfigError } from '@/core/errors.ts'; +import type { AnchorKitConfig, Asset, NetworkConfig } from '@/types/config.ts'; +import { Networks } from '@stellar/stellar-sdk'; +import { DatabaseUrlSchema } from '@/utils/validation.ts'; /** * AnchorConfig @@ -18,9 +21,9 @@ export class AnchorConfig { */ private mergeWithDefaults(input: Partial): AnchorKitConfig { const defaultNetworkPassphrases: Record = { - public: 'Public Global Stellar Network ; September 2015', - testnet: 'Test SDF Network ; September 2015', - futurenet: 'Test SDF Future Network ; Fall 2022', + public: Networks.PUBLIC, + testnet: Networks.TESTNET, + futurenet: Networks.FUTURENET, }; const hasNetworkProp = Object.prototype.hasOwnProperty.call(input, 'network'); @@ -39,9 +42,7 @@ export class AnchorConfig { }; } - const operationalInput = input.operational as - | Partial - | undefined; + const operationalInput = input.operational; const operational = { name: operationalInput?.name, website: operationalInput?.website, @@ -56,7 +57,9 @@ export class AnchorConfig { // Keep original input values for required sections so explicit `undefined` // is preserved (validation will catch missing required fields). - const merged: any = { + const merged: { + [K in keyof AnchorKitConfig]: AnchorKitConfig[K] | undefined; + } = { network, server: input.server, security: input.security, @@ -65,7 +68,32 @@ export class AnchorConfig { kycRequired: input.kycRequired, operational, metadata: input.metadata, - framework: input.framework, + framework: input.framework + ? { + ...input.framework, + queue: { + backend: input.framework.queue?.backend ?? 'memory', + concurrency: input.framework.queue?.concurrency ?? 1, + }, + watchers: { + enabled: input.framework.watchers?.enabled ?? true, + pollIntervalMs: input.framework.watchers?.pollIntervalMs ?? 15000, + transactionTimeoutMs: input.framework.watchers?.transactionTimeoutMs ?? 300000, + retentionDays: input.framework.watchers?.retentionDays ?? 90, + }, + http: { + maxBodyBytes: input.framework.http?.maxBodyBytes ?? 1024 * 1024, + }, + rateLimit: { + windowMs: input.framework.rateLimit?.windowMs ?? 60000, + authChallengeMax: input.framework.rateLimit?.authChallengeMax ?? 30, + authTokenMax: input.framework.rateLimit?.authTokenMax ?? 30, + webhookMax: input.framework.rateLimit?.webhookMax ?? 120, + depositMax: input.framework.rateLimit?.depositMax ?? 60, + }, + } + : undefined, + webhooks: input.webhooks, }; return merged as AnchorKitConfig; @@ -76,10 +104,11 @@ export class AnchorConfig { */ private deepFreeze(obj: T): T { if (obj === null || typeof obj !== 'object') return obj; + const record = obj as Record; // Freeze children first - for (const key of Object.getOwnPropertyNames(obj) as Array) { - const value = (obj as any)[key]; + for (const key of Reflect.ownKeys(record)) { + const value = record[key]; if (value && typeof value === 'object' && !Object.isFrozen(value)) { this.deepFreeze(value); } @@ -144,13 +173,13 @@ export class AnchorConfig { switch (network) { case 'public': - defaultPassphrase = 'Public Global Stellar Network ; September 2015'; + defaultPassphrase = Networks.PUBLIC; break; case 'testnet': - defaultPassphrase = 'Test SDF Network ; September 2015'; + defaultPassphrase = Networks.TESTNET; break; case 'futurenet': - defaultPassphrase = 'Test SDF Future Network ; Fall 2022'; + defaultPassphrase = Networks.FUTURENET; break; default: return false; @@ -162,89 +191,24 @@ export class AnchorConfig { /** * Validate the configuration object for required secrets, * URLs, network values, and basic structural invariants. - * Throws ConfigurationError if validation fails. + * Throws ConfigError if validation fails. */ public validate(): void { - if (!this.config) { - throw new ConfigurationError('Configuration object is missing'); - } - - const { network, server, security, assets, framework } = this.config; - - // Validate Required Top-Level Fields - if (!network) { - throw new ConfigurationError('Missing required top-level field: network'); - } - if (!server) { - throw new ConfigurationError('Missing required top-level field: server'); - } - if (!security) { - throw new ConfigurationError('Missing required top-level field: security'); - } - if (!assets) { - throw new ConfigurationError('Missing required top-level field: assets'); - } - if (!framework) { - throw new ConfigurationError('Missing required top-level field: framework'); - } - - // Validate Required Secrets - if (!security.sep10SigningKey) { - throw new ConfigurationError('Missing required secret: security.sep10SigningKey'); - } - if (!security.interactiveJwtSecret) { - throw new ConfigurationError('Missing required secret: security.interactiveJwtSecret'); - } - if (!security.distributionAccountSecret) { - throw new ConfigurationError('Missing required secret: security.distributionAccountSecret'); - } - - // Validate Assets configuration - if (!assets.assets || !Array.isArray(assets.assets) || assets.assets.length === 0) { - throw new ConfigurationError('At least one asset must be configured in assets.assets'); - } - - // Validate Framework Database config - if (!framework.database || !framework.database.provider || !framework.database.url) { - throw new ConfigurationError('Missing required database configuration in framework.database'); - } - - // Validate database URL loosely (could be a connection string or file path) - if (!this.isValidDatabaseUrl(framework.database.url)) { - throw new ConfigurationError('Invalid database URL format'); - } - - // Validate specific URLs if they are provided - if (server.interactiveDomain && !this.isValidUrl(server.interactiveDomain)) { - throw new ConfigurationError('Invalid URL format for server.interactiveDomain'); - } - - if (network.horizonUrl && !this.isValidUrl(network.horizonUrl)) { - throw new ConfigurationError('Invalid URL format for network.horizonUrl'); - } - - const { metadata } = this.config; - if (metadata?.tomlUrl && !this.isValidUrl(metadata.tomlUrl)) { - throw new ConfigurationError('Invalid URL format for metadata.tomlUrl'); - } - - // Validate network-related values - const validNetworks = ['public', 'testnet', 'futurenet']; - if (!validNetworks.includes(network.network)) { - throw new ConfigurationError( - `Invalid network: ${network.network}. Must be one of: ${validNetworks.join(', ')}`, - ); + try { + AnchorKitConfigSchema.validate(this.config); + } catch (error) { + throw new ConfigError((error as Error).message); } } /** * Helper to check for standard HTTP/HTTPS URLs + * @deprecated Use ValidationUtils.isValidUrl instead */ private isValidUrl(urlString: string): boolean { try { - const UrlCtor = (globalThis as any).URL; - if (typeof UrlCtor !== 'function') return false; - const url = new UrlCtor(urlString); + if (typeof URL !== 'function') return false; + const url = new URL(urlString); return url.protocol === 'http:' || url.protocol === 'https:'; } catch { return false; @@ -253,24 +217,9 @@ export class AnchorConfig { /** * Helper to validate database connection strings or file paths + * @deprecated Use ValidationUtils.isValidDatabaseUrl instead */ private isValidDatabaseUrl(urlString: string): boolean { - if (!urlString || typeof urlString !== 'string') return false; - - const validSchemes = ['postgresql:', 'postgres:', 'mysql:', 'mysql2:', 'sqlite:', 'file:']; - - if (validSchemes.some((scheme) => urlString.startsWith(scheme))) { - return true; - } - - // In case it's another valid URI - try { - const UrlCtor = (globalThis as any).URL; - if (typeof UrlCtor !== 'function') throw new Error('URL not available'); - new UrlCtor(urlString); - return true; - } catch { - return false; - } + return DatabaseUrlSchema.isValid(urlString); } } diff --git a/src/core/errors.ts b/src/core/errors.ts index 683ab13..85e37cf 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -1,18 +1,13 @@ -export class AnchorKitError extends Error { - public statusCode: number; - public errorCode: string; +import { SepErrorCode } from '../types/foundation.ts'; + +export abstract class AnchorKitError extends Error { + public abstract readonly statusCode: number; + public abstract readonly errorCode: string; public context?: Record; - constructor( - message: string, - statusCode = 500, - errorCode = 'INTERNAL_ERROR', - context?: Record, - ) { + constructor(message: string, context?: Record) { super(message); this.name = this.constructor.name; - this.statusCode = statusCode; - this.errorCode = errorCode; this.context = context; } @@ -20,14 +15,111 @@ export class AnchorKitError extends Error { return { error: this.errorCode, message: this.message, - ...(typeof (globalThis as any).process !== 'undefined' && - (globalThis as any).process.env?.NODE_ENV === 'development' && { context: this.context }), + ...(typeof globalThis.process !== 'undefined' && + globalThis.process.env?.NODE_ENV === 'development' && { context: this.context }), }; } } -export class ConfigurationError extends AnchorKitError { +/** + * Error raised when the anchor's internal configuration is invalid or incomplete. + */ +export class ConfigError extends AnchorKitError { + public readonly statusCode = 500; + public readonly errorCode = 'INVALID_CONFIG'; + + constructor(message: string, context?: Record) { + super(message, context); + } +} + +/** + * Error raised when a request fails validation (e.g. invalid parameters). + */ +export class ValidationError extends AnchorKitError { + public readonly statusCode = 400; + public readonly errorCode = 'INVALID_REQUEST'; + + constructor(message: string, context?: Record) { + super(message, context); + } +} + +/** + * Error representing a standard SEP protocol error. + */ +export class SepProtocolError extends AnchorKitError { + public readonly statusCode = 400; + public readonly errorCode: SepErrorCode; + public sepErrorType?: string; + + constructor( + message: string, + errorCode: SepErrorCode, + sepErrorType?: string, + context?: Record, + ) { + const meta = { ...context, errorCode, sepErrorType } as Record; + super(message, meta); + this.errorCode = errorCode; + this.sepErrorType = sepErrorType; + } +} + +export class TransactionStateError extends AnchorKitError { + public readonly statusCode = 400; + public readonly errorCode = 'INVALID_STATE_TRANSITION'; + public currentStatus?: string; + public attemptedStatus?: string; + + constructor( + message: string, + currentStatus?: string, + attemptedStatus?: string, + context?: Record, + ) { + const meta = { ...context, currentStatus, attemptedStatus } as Record; + super(message, meta); + this.currentStatus = currentStatus; + this.attemptedStatus = attemptedStatus; + } +} + +export class RailError extends AnchorKitError { + public readonly statusCode = 500; + public readonly errorCode = 'RAIL_ERROR'; + public railName?: string; + + constructor(message: string, railName?: string, context?: Record) { + const meta = { ...context, railName } as Record; + super(message, meta); + this.railName = railName; + } +} + +/** + * Error raised when a network request fails or upstream service is unreachable. + */ +export class NetworkError extends AnchorKitError { + public readonly statusCode = 502; + public readonly errorCode = 'NETWORK_ERROR'; + public httpStatusFromUpstream?: number; + + constructor(message: string, httpStatusFromUpstream?: number, context?: Record) { + const meta = { ...context, httpStatusFromUpstream } as Record; + super(message, meta); + this.httpStatusFromUpstream = httpStatusFromUpstream; + } +} + +/** + * Error raised when a cryptographic operation fails (e.g. signing, encryption). + */ +export class CryptoError extends AnchorKitError { + public readonly statusCode = 500; + public readonly errorCode = 'CRYPTO_ERROR'; + constructor(message: string, context?: Record) { - super(message, 500, 'INVALID_CONFIG', context); + super(message, context); } } diff --git a/src/core/factory.ts b/src/core/factory.ts new file mode 100644 index 0000000..9e4dd26 --- /dev/null +++ b/src/core/factory.ts @@ -0,0 +1,236 @@ +import { AnchorKitConfig } from '@/types/config.ts'; +import { AnchorConfig } from '@/core/config.ts'; +import { AnchorPlugin } from '@/types/plugin.ts'; +import { + createSqlDatabaseAdapter, + makeSqliteDbUrlForTests, +} from '@/runtime/database/sql-database-adapter.ts'; +import { InMemoryQueueAdapter } from '@/runtime/queue/in-memory-queue.ts'; +import type { + DatabaseAdapter, + QueueAdapter, + QueueJob, + Watcher, + WebhookProcessor, +} from '@/runtime/interfaces.ts'; +import { DefaultWebhookProcessor } from '@/runtime/webhooks/default-webhook-processor.ts'; +import { AnchorExpressRouter, type ExpressLikeMiddleware } from '@/runtime/http/express-router.ts'; +import { TransactionWatcher } from '@/runtime/watchers/transaction-watcher.ts'; +import { ConfigError } from '@/core/errors.ts'; + +/** + * AnchorInstance + * Represents the core SDK instance controlling the anchor's behavior. + */ +export class AnchorInstance { + public readonly config: AnchorConfig; + private plugins: Map = new Map(); + + private database: DatabaseAdapter | null = null; + private queue: QueueAdapter | null = null; + private webhookProcessor: WebhookProcessor | null = null; + private watchers: Watcher[] = []; + private expressRouter: ExpressLikeMiddleware | null = null; + + private initialized = false; + private backgroundJobsRunning = false; + + constructor(config: Partial) { + this.config = new AnchorConfig(config); + this.config.validate(); + } + + /** + * Register a plugin with the anchor instance. + */ + public use(plugin: AnchorPlugin): this { + if (this.plugins.has(plugin.id)) { + throw new Error(`Plugin with id "${plugin.id}" is already registered.`); + } + this.plugins.set(plugin.id, plugin); + return this; + } + + /** + * Initialize registered plugins and all runtime services. + */ + public async init(): Promise { + if (this.initialized) return; + + const frameworkConfig = this.config.get('framework'); + + this.database = createSqlDatabaseAdapter(frameworkConfig.database); + await this.database.connect(); + await this.database.migrate(); + + const queueConcurrency = frameworkConfig.queue?.concurrency ?? 1; + this.queue = new InMemoryQueueAdapter({ concurrency: queueConcurrency }); + + this.webhookProcessor = new DefaultWebhookProcessor({ + config: this.config.getConfig(), + database: this.database, + }); + + const watchersEnabled = frameworkConfig.watchers?.enabled ?? true; + if (watchersEnabled) { + this.watchers = [ + new TransactionWatcher(this.database, this.queue, { + pollIntervalMs: frameworkConfig.watchers?.pollIntervalMs ?? 15000, + transactionTimeoutMs: frameworkConfig.watchers?.transactionTimeoutMs ?? 300000, + retentionDays: frameworkConfig.watchers?.retentionDays ?? 90, + }), + ]; + } + + this.expressRouter = new AnchorExpressRouter({ + config: this.config, + database: this.database, + webhookProcessor: this.webhookProcessor, + }).getMiddleware(); + + for (const plugin of this.plugins.values()) { + if (plugin.init) { + await plugin.init(this); + } + } + + this.initialized = true; + } + + /** + * Start queue workers and watcher services. + */ + public async startBackgroundJobs(): Promise { + this.ensureInitialized(); + if (this.backgroundJobsRunning) return; + + await this.requireQueue().start(async (job) => this.processQueueJob(job)); + + for (const watcher of this.watchers) { + await watcher.start(); + } + + this.backgroundJobsRunning = true; + } + + /** + * Stop watcher services and queue workers. + */ + public async stopBackgroundJobs(): Promise { + if (!this.initialized || !this.backgroundJobsRunning) return; + + for (const watcher of this.watchers) { + await watcher.stop(); + } + + await this.requireQueue().stop(); + this.backgroundJobsRunning = false; + } + + /** + * Cleanly shutdown all services. + */ + public async shutdown(): Promise { + if (!this.initialized) return; + await this.stopBackgroundJobs(); + await this.requireDatabase().disconnect(); + this.initialized = false; + } + + /** + * Return middleware compatible with Express router mounting. + * + * Example: app.use('/anchor', anchor.getExpressRouter()) + */ + public getExpressRouter(): ExpressLikeMiddleware { + this.ensureInitialized(); + if (!this.expressRouter) { + throw new ConfigError('Express router has not been initialized'); + } + return this.expressRouter; + } + + /** + * Get a registered plugin by its ID. + */ + public getPlugin(id: string): T | undefined { + return this.plugins.get(id) as T; + } + + /** + * Test helper for verifying background processing behavior. + */ + public async getProcessedWatcherTaskCount(): Promise { + this.ensureInitialized(); + return this.requireDatabase().countProcessedWatcherTasks(); + } + + private ensureInitialized(): void { + if (!this.initialized) { + throw new ConfigError('Anchor is not initialized. Call init() first.'); + } + } + + private requireDatabase(): DatabaseAdapter { + if (!this.database) { + throw new ConfigError('Database adapter is not initialized'); + } + return this.database; + } + + private requireQueue(): QueueAdapter { + if (!this.queue) { + throw new ConfigError('Queue adapter is not initialized'); + } + return this.queue; + } + + private async processQueueJob(job: QueueJob): Promise { + const database = this.requireDatabase(); + + if (job.type === 'expire_transaction') { + const transactionIdValue = job.payload.transactionId; + if (typeof transactionIdValue !== 'string' || transactionIdValue.length === 0) { + return; + } + + await database.updateTransactionStatus(transactionIdValue, 'expired'); + return; + } + + if (job.type === 'process_watcher_task') { + const watcherTaskIdValue = job.payload.watcherTaskId; + if (typeof watcherTaskIdValue !== 'string' || watcherTaskIdValue.length === 0) { + return; + } + + await database.updateWatcherTaskStatus({ + id: watcherTaskIdValue, + status: 'processed', + }); + return; + } + + if (job.type === 'cleanup_records') { + const retentionDaysValue = job.payload.retentionDays; + if (typeof retentionDaysValue !== 'number' || !Number.isFinite(retentionDaysValue)) { + return; + } + + const cutoffMs = Date.now() - retentionDaysValue * 24 * 60 * 60 * 1000; + const cutoffIso = new Date(cutoffMs).toISOString(); + await database.cleanupOldRecords(cutoffIso); + return; + } + } +} + +/** + * createAnchor + * Factory function to initialize a new Anchor-Kit instance. + */ +export function createAnchor(config: Partial): AnchorInstance { + return new AnchorInstance(config); +} + +export { makeSqliteDbUrlForTests }; diff --git a/src/core/index.ts b/src/core/index.ts index eb79827..fc19662 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,2 +1,5 @@ -export * from './config.ts'; -export * from './errors.ts'; +export * from './config'; +export * from './factory'; +// export * from './sep24'; +// export * from './plugin'; +// export type { KycStatus } from './foundation'; diff --git a/src/core/sep24/README.md b/src/core/sep24/README.md new file mode 100644 index 0000000..801dda8 --- /dev/null +++ b/src/core/sep24/README.md @@ -0,0 +1,5 @@ +# Planned Module Stub + +This directory is a placeholder for SEP-24 runtime module implementation. + +Current SEP-24 support in this repository is type-level (`src/types/sep24/**`) only. diff --git a/src/core/sep31/README.md b/src/core/sep31/README.md new file mode 100644 index 0000000..bf99456 --- /dev/null +++ b/src/core/sep31/README.md @@ -0,0 +1,5 @@ +# Planned Module Stub + +This directory is a placeholder for the SEP-31 module implementation. + +No runtime SEP-31 implementation is shipped yet. diff --git a/src/core/sep6/README.md b/src/core/sep6/README.md new file mode 100644 index 0000000..48202ad --- /dev/null +++ b/src/core/sep6/README.md @@ -0,0 +1,5 @@ +# Planned Module Stub + +This directory is a placeholder for the SEP-6 module implementation. + +No runtime SEP-6 implementation is shipped yet. diff --git a/src/index.ts b/src/index.ts index 4baa75f..c4e5d7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,15 @@ * @see https://github.com/0xNgoo/anchor-kit */ -// Export all types export * from './types'; export * from './services'; +export { AnchorInstance, createAnchor, makeSqliteDbUrlForTests } from './core/factory'; +export * from './core/errors'; +export * as utils from './utils'; +export { AssetSchema, DatabaseUrlSchema, SecurityConfigSchema } from './utils'; +export type { + DatabaseAdapter, + QueueAdapter, + Watcher, + WebhookProcessor, +} from './runtime/interfaces.ts'; diff --git a/src/runtime/database/sql-database-adapter.ts b/src/runtime/database/sql-database-adapter.ts new file mode 100644 index 0000000..4ff0a7a --- /dev/null +++ b/src/runtime/database/sql-database-adapter.ts @@ -0,0 +1,753 @@ +import { randomUUID } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Database } from 'bun:sqlite'; +import { ConfigError } from '@/core/errors.ts'; +import type { + AuthChallengeRecord, + DatabaseAdapter, + IdempotencyRecord, + InteractiveTransactionRecord, + WatcherTaskRecord, + WebhookEventRecord, +} from '@/runtime/interfaces.ts'; +import type { FrameworkConfig } from '@/types/config.ts'; + +type SqliteLike = Database; + +interface PostgresClient { + connect(): Promise; + end(): Promise; + query>(sql: string, values?: unknown[]): Promise<{ rows: T[] }>; +} + +const SQLITE_FILE_PREFIX = 'file:'; + +function nowIso(): string { + return new Date().toISOString(); +} + +function toSqlitePath(url: string): string { + if (url.startsWith(SQLITE_FILE_PREFIX)) { + return url.slice(SQLITE_FILE_PREFIX.length); + } + return url; +} + +function parseJsonObject(value: string): Record { + const parsed: unknown = JSON.parse(value); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + return parsed as Record; +} + +export class SqlDatabaseAdapter implements DatabaseAdapter { + private readonly provider: FrameworkConfig['database']['provider']; + private readonly url: string; + private sqlite: SqliteLike | null = null; + private postgres: PostgresClient | null = null; + + constructor(databaseConfig: FrameworkConfig['database']) { + this.provider = databaseConfig.provider; + this.url = databaseConfig.url; + } + + public async connect(): Promise { + if (this.provider === 'sqlite') { + this.sqlite = new Database(toSqlitePath(this.url)); + return; + } + + if (this.provider === 'postgres') { + const moduleName = 'pg'; + const pgModuleUnknown: unknown = await import(moduleName); + const pgModule = pgModuleUnknown as { + Client: new (config: { connectionString: string }) => PostgresClient; + }; + this.postgres = new pgModule.Client({ connectionString: this.url }); + await this.postgres.connect(); + return; + } + + throw new ConfigError(`Unsupported database provider: ${this.provider}`); + } + + public async disconnect(): Promise { + if (this.sqlite) { + this.sqlite.close(); + this.sqlite = null; + } + + if (this.postgres) { + await this.postgres.end(); + this.postgres = null; + } + } + + public async migrate(): Promise { + if (this.sqlite) { + this.sqlite.exec(` + CREATE TABLE IF NOT EXISTS auth_challenges ( + id TEXT PRIMARY KEY, + account TEXT NOT NULL, + challenge TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + consumed_at TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS interactive_transactions ( + id TEXT PRIMARY KEY, + account TEXT NOT NULL, + kind TEXT NOT NULL, + asset_code TEXT NOT NULL, + amount TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS idempotency_keys ( + id TEXT PRIMARY KEY, + scope TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + request_hash TEXT NOT NULL, + status_code INTEGER NOT NULL, + response_body TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(scope, idempotency_key) + ); + + CREATE TABLE IF NOT EXISTS webhook_events ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE, + provider TEXT NOT NULL, + payload TEXT NOT NULL, + status TEXT NOT NULL, + error_message TEXT, + processed_at TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS watcher_tasks ( + id TEXT PRIMARY KEY, + watcher_name TEXT NOT NULL, + payload TEXT NOT NULL, + status TEXT NOT NULL, + error_message TEXT, + processed_at TEXT, + created_at TEXT NOT NULL + ); + `); + return; + } + + if (this.postgres) { + await this.postgres.query(` + CREATE TABLE IF NOT EXISTS auth_challenges ( + id TEXT PRIMARY KEY, + account TEXT NOT NULL, + challenge TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL + ); + `); + await this.postgres.query(` + CREATE TABLE IF NOT EXISTS interactive_transactions ( + id TEXT PRIMARY KEY, + account TEXT NOT NULL, + kind TEXT NOT NULL, + asset_code TEXT NOT NULL, + amount TEXT NOT NULL, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + ); + `); + await this.postgres.query(` + CREATE TABLE IF NOT EXISTS idempotency_keys ( + id TEXT PRIMARY KEY, + scope TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + request_hash TEXT NOT NULL, + status_code INTEGER NOT NULL, + response_body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + UNIQUE(scope, idempotency_key) + ); + `); + await this.postgres.query(` + CREATE TABLE IF NOT EXISTS webhook_events ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE, + provider TEXT NOT NULL, + payload JSONB NOT NULL, + status TEXT NOT NULL, + error_message TEXT, + processed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL + ); + `); + await this.postgres.query(` + CREATE TABLE IF NOT EXISTS watcher_tasks ( + id TEXT PRIMARY KEY, + watcher_name TEXT NOT NULL, + payload JSONB NOT NULL, + status TEXT NOT NULL, + error_message TEXT, + processed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL + ); + `); + return; + } + + throw new ConfigError('Database not connected'); + } + + public async insertAuthChallenge(input: { + id: string; + account: string; + challenge: string; + expiresAt: string; + }): Promise { + const createdAt = nowIso(); + if (this.sqlite) { + this.sqlite + .prepare( + 'INSERT INTO auth_challenges (id, account, challenge, expires_at, consumed_at, created_at) VALUES (?, ?, ?, ?, NULL, ?)', + ) + .run(input.id, input.account, input.challenge, input.expiresAt, createdAt); + return; + } + + await this.requirePostgres().query( + 'INSERT INTO auth_challenges (id, account, challenge, expires_at, consumed_at, created_at) VALUES ($1, $2, $3, $4, NULL, $5)', + [input.id, input.account, input.challenge, input.expiresAt, createdAt], + ); + } + + public async getAuthChallengeByChallenge(challenge: string): Promise { + if (this.sqlite) { + const row = this.sqlite + .prepare('SELECT * FROM auth_challenges WHERE challenge = ? LIMIT 1') + .get(challenge) as Record | null; + + if (!row) return null; + return { + id: String(row.id), + account: String(row.account), + challenge: String(row.challenge), + expiresAt: String(row.expires_at), + consumedAt: row.consumed_at ? String(row.consumed_at) : null, + createdAt: String(row.created_at), + }; + } + + const response = await this.requirePostgres().query>( + 'SELECT * FROM auth_challenges WHERE challenge = $1 LIMIT 1', + [challenge], + ); + + const row = response.rows[0]; + if (!row) return null; + return { + id: String(row.id), + account: String(row.account), + challenge: String(row.challenge), + expiresAt: String(row.expires_at), + consumedAt: row.consumed_at ? String(row.consumed_at) : null, + createdAt: String(row.created_at), + }; + } + + public async markAuthChallengeConsumed(id: string): Promise { + const consumedAt = nowIso(); + if (this.sqlite) { + this.sqlite + .prepare('UPDATE auth_challenges SET consumed_at = ? WHERE id = ?') + .run(consumedAt, id); + return; + } + + await this.requirePostgres().query( + 'UPDATE auth_challenges SET consumed_at = $1 WHERE id = $2', + [consumedAt, id], + ); + } + + public async insertInteractiveTransaction(input: { + id: string; + account: string; + kind: 'deposit'; + assetCode: string; + amount: string; + status: string; + }): Promise { + const createdAt = nowIso(); + const updatedAt = createdAt; + + if (this.sqlite) { + this.sqlite + .prepare( + 'INSERT INTO interactive_transactions (id, account, kind, asset_code, amount, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + ) + .run( + input.id, + input.account, + input.kind, + input.assetCode, + input.amount, + input.status, + createdAt, + updatedAt, + ); + } else { + await this.requirePostgres().query( + 'INSERT INTO interactive_transactions (id, account, kind, asset_code, amount, status, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)', + [ + input.id, + input.account, + input.kind, + input.assetCode, + input.amount, + input.status, + createdAt, + updatedAt, + ], + ); + } + + return { + id: input.id, + account: input.account, + kind: input.kind, + assetCode: input.assetCode, + amount: input.amount, + status: input.status, + createdAt, + updatedAt, + }; + } + + public async getInteractiveTransactionById( + id: string, + ): Promise { + if (this.sqlite) { + const row = this.sqlite + .prepare('SELECT * FROM interactive_transactions WHERE id = ? LIMIT 1') + .get(id) as Record | null; + + if (!row) return null; + return this.mapTransactionRow(row); + } + + const response = await this.requirePostgres().query>( + 'SELECT * FROM interactive_transactions WHERE id = $1 LIMIT 1', + [id], + ); + + const row = response.rows[0]; + if (!row) return null; + return this.mapTransactionRow(row); + } + + public async listPendingTransactionsBefore( + cutoffIso: string, + ): Promise { + if (this.sqlite) { + const rows = this.sqlite + .prepare( + "SELECT * FROM interactive_transactions WHERE status = 'pending_user_transfer_start' AND created_at < ?", + ) + .all(cutoffIso) as Record[]; + return rows.map((row) => this.mapTransactionRow(row)); + } + + const response = await this.requirePostgres().query>( + "SELECT * FROM interactive_transactions WHERE status = 'pending_user_transfer_start' AND created_at < $1", + [cutoffIso], + ); + return response.rows.map((row) => this.mapTransactionRow(row)); + } + + public async updateTransactionStatus(id: string, status: string): Promise { + const updatedAt = nowIso(); + if (this.sqlite) { + this.sqlite + .prepare('UPDATE interactive_transactions SET status = ?, updated_at = ? WHERE id = ?') + .run(status, updatedAt, id); + return; + } + + await this.requirePostgres().query( + 'UPDATE interactive_transactions SET status = $1, updated_at = $2 WHERE id = $3', + [status, updatedAt, id], + ); + } + + public async getIdempotencyRecord( + scope: string, + idempotencyKey: string, + ): Promise { + if (this.sqlite) { + const row = this.sqlite + .prepare('SELECT * FROM idempotency_keys WHERE scope = ? AND idempotency_key = ? LIMIT 1') + .get(scope, idempotencyKey) as Record | null; + return row ? this.mapIdempotencyRow(row) : null; + } + + const response = await this.requirePostgres().query>( + 'SELECT * FROM idempotency_keys WHERE scope = $1 AND idempotency_key = $2 LIMIT 1', + [scope, idempotencyKey], + ); + + const row = response.rows[0]; + return row ? this.mapIdempotencyRow(row) : null; + } + + public async insertIdempotencyRecord(input: { + id: string; + scope: string; + idempotencyKey: string; + requestHash: string; + statusCode: number; + responseBody: string; + }): Promise { + const createdAt = nowIso(); + + if (this.sqlite) { + this.sqlite + .prepare( + 'INSERT INTO idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + ) + .run( + input.id, + input.scope, + input.idempotencyKey, + input.requestHash, + input.statusCode, + input.responseBody, + createdAt, + ); + return; + } + + await this.requirePostgres().query( + 'INSERT INTO idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)', + [ + input.id, + input.scope, + input.idempotencyKey, + input.requestHash, + input.statusCode, + input.responseBody, + createdAt, + ], + ); + } + + public async insertWebhookEvent(input: { + id: string; + eventId: string; + provider: string; + payload: Record; + }): Promise<{ record: WebhookEventRecord; inserted: boolean }> { + const createdAt = nowIso(); + + if (this.sqlite) { + const existing = this.sqlite + .prepare('SELECT * FROM webhook_events WHERE event_id = ? LIMIT 1') + .get(input.eventId) as Record | null; + if (existing) { + return { record: this.mapWebhookRow(existing), inserted: false }; + } + + this.sqlite + .prepare( + 'INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) VALUES (?, ?, ?, ?, ?, NULL, NULL, ?)', + ) + .run( + input.id, + input.eventId, + input.provider, + JSON.stringify(input.payload), + 'pending', + createdAt, + ); + + const inserted = this.sqlite + .prepare('SELECT * FROM webhook_events WHERE id = ? LIMIT 1') + .get(input.id) as Record | null; + + if (!inserted) { + throw new ConfigError('Failed to insert webhook event'); + } + + return { record: this.mapWebhookRow(inserted), inserted: true }; + } + + const existingPg = await this.requirePostgres().query>( + 'SELECT * FROM webhook_events WHERE event_id = $1 LIMIT 1', + [input.eventId], + ); + + if (existingPg.rows[0]) { + return { record: this.mapWebhookRow(existingPg.rows[0]), inserted: false }; + } + + await this.requirePostgres().query( + 'INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) VALUES ($1, $2, $3, $4::jsonb, $5, NULL, NULL, $6)', + [ + input.id, + input.eventId, + input.provider, + JSON.stringify(input.payload), + 'pending', + createdAt, + ], + ); + + const insertedPg = await this.requirePostgres().query>( + 'SELECT * FROM webhook_events WHERE id = $1 LIMIT 1', + [input.id], + ); + + const row = insertedPg.rows[0]; + if (!row) { + throw new ConfigError('Failed to insert webhook event'); + } + + return { record: this.mapWebhookRow(row), inserted: true }; + } + + public async updateWebhookEventStatus(input: { + id: string; + status: 'processed' | 'failed'; + errorMessage?: string; + }): Promise { + const processedAt = nowIso(); + const errorMessage = input.errorMessage ?? null; + + if (this.sqlite) { + this.sqlite + .prepare( + 'UPDATE webhook_events SET status = ?, error_message = ?, processed_at = ? WHERE id = ?', + ) + .run(input.status, errorMessage, processedAt, input.id); + return; + } + + await this.requirePostgres().query( + 'UPDATE webhook_events SET status = $1, error_message = $2, processed_at = $3 WHERE id = $4', + [input.status, errorMessage, processedAt, input.id], + ); + } + + public async insertWatcherTask(input: { + id: string; + watcherName: string; + payload: Record; + }): Promise { + const createdAt = nowIso(); + + if (this.sqlite) { + this.sqlite + .prepare( + 'INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) VALUES (?, ?, ?, ?, NULL, NULL, ?)', + ) + .run(input.id, input.watcherName, JSON.stringify(input.payload), 'pending', createdAt); + return; + } + + await this.requirePostgres().query( + 'INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) VALUES ($1, $2, $3::jsonb, $4, NULL, NULL, $5)', + [input.id, input.watcherName, JSON.stringify(input.payload), 'pending', createdAt], + ); + } + + public async listPendingWatcherTasks(limit: number): Promise { + if (this.sqlite) { + const rows = this.sqlite + .prepare('SELECT * FROM watcher_tasks WHERE status = ? ORDER BY created_at ASC LIMIT ?') + .all('pending', limit) as Record[]; + + return rows.map((row) => this.mapWatcherRow(row)); + } + + const response = await this.requirePostgres().query>( + 'SELECT * FROM watcher_tasks WHERE status = $1 ORDER BY created_at ASC LIMIT $2', + ['pending', limit], + ); + + return response.rows.map((row) => this.mapWatcherRow(row)); + } + + public async updateWatcherTaskStatus(input: { + id: string; + status: 'processed' | 'failed'; + errorMessage?: string; + }): Promise { + const processedAt = nowIso(); + const errorMessage = input.errorMessage ?? null; + + if (this.sqlite) { + this.sqlite + .prepare( + 'UPDATE watcher_tasks SET status = ?, error_message = ?, processed_at = ? WHERE id = ?', + ) + .run(input.status, errorMessage, processedAt, input.id); + return; + } + + await this.requirePostgres().query( + 'UPDATE watcher_tasks SET status = $1, error_message = $2, processed_at = $3 WHERE id = $4', + [input.status, errorMessage, processedAt, input.id], + ); + } + + public async countProcessedWatcherTasks(): Promise { + if (this.sqlite) { + const row = this.sqlite + .prepare("SELECT COUNT(*) AS count FROM watcher_tasks WHERE status = 'processed'") + .get() as Record; + return Number(row.count ?? 0); + } + + const response = await this.requirePostgres().query>( + "SELECT COUNT(*)::int AS count FROM watcher_tasks WHERE status = 'processed'", + ); + return Number(response.rows[0]?.count ?? 0); + } + + public async cleanupOldRecords(cutoffIso: string): Promise { + if (this.sqlite) { + this.sqlite.prepare('DELETE FROM auth_challenges WHERE expires_at < ?').run(cutoffIso); + this.sqlite.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoffIso); + this.sqlite + .prepare( + "DELETE FROM webhook_events WHERE created_at < ? AND status IN ('processed', 'failed')", + ) + .run(cutoffIso); + this.sqlite + .prepare( + "DELETE FROM watcher_tasks WHERE created_at < ? AND status IN ('processed', 'failed')", + ) + .run(cutoffIso); + return; + } + + await this.requirePostgres().query('DELETE FROM auth_challenges WHERE expires_at < $1', [ + cutoffIso, + ]); + await this.requirePostgres().query('DELETE FROM idempotency_keys WHERE created_at < $1', [ + cutoffIso, + ]); + await this.requirePostgres().query( + "DELETE FROM webhook_events WHERE created_at < $1 AND status IN ('processed', 'failed')", + [cutoffIso], + ); + await this.requirePostgres().query( + "DELETE FROM watcher_tasks WHERE created_at < $1 AND status IN ('processed', 'failed')", + [cutoffIso], + ); + } + + private mapTransactionRow(row: Record): InteractiveTransactionRecord { + return { + id: String(row.id), + account: String(row.account), + kind: 'deposit', + assetCode: String(row.asset_code), + amount: String(row.amount), + status: String(row.status), + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + }; + } + + private mapIdempotencyRow(row: Record): IdempotencyRecord { + return { + id: String(row.id), + scope: String(row.scope), + idempotencyKey: String(row.idempotency_key), + requestHash: String(row.request_hash), + statusCode: Number(row.status_code), + responseBody: String(row.response_body), + createdAt: String(row.created_at), + }; + } + + private mapWebhookRow(row: Record): WebhookEventRecord { + const payloadValue = row.payload; + const payload = + typeof payloadValue === 'string' + ? parseJsonObject(payloadValue) + : ((payloadValue as Record) ?? {}); + + const statusRaw = String(row.status); + const status = statusRaw === 'processed' || statusRaw === 'failed' ? statusRaw : 'pending'; + + return { + id: String(row.id), + eventId: String(row.event_id), + provider: String(row.provider), + payload, + status, + errorMessage: row.error_message ? String(row.error_message) : null, + processedAt: row.processed_at ? String(row.processed_at) : null, + createdAt: String(row.created_at), + }; + } + + private mapWatcherRow(row: Record): WatcherTaskRecord { + const payloadValue = row.payload; + const payload = + typeof payloadValue === 'string' + ? parseJsonObject(payloadValue) + : ((payloadValue as Record) ?? {}); + + const statusRaw = String(row.status); + const status = statusRaw === 'processed' || statusRaw === 'failed' ? statusRaw : 'pending'; + + return { + id: String(row.id), + watcherName: String(row.watcher_name), + payload, + status, + errorMessage: row.error_message ? String(row.error_message) : null, + processedAt: row.processed_at ? String(row.processed_at) : null, + createdAt: String(row.created_at), + }; + } + + private requirePostgres(): PostgresClient { + if (!this.postgres) { + throw new ConfigError('PostgreSQL client is not connected'); + } + return this.postgres; + } +} + +export function createSqlDatabaseAdapter( + databaseConfig: FrameworkConfig['database'], +): DatabaseAdapter { + if (databaseConfig.provider === 'mysql') { + throw new ConfigError('MySQL is not implemented in this MVP. Use postgres or sqlite.'); + } + + if (databaseConfig.provider === 'postgres') { + const hasPgModule = Boolean((globalThis as Record).process); + if (!hasPgModule) { + throw new ConfigError('PostgreSQL runtime is unavailable'); + } + } + + return new SqlDatabaseAdapter(databaseConfig); +} + +export function makeSqliteDbUrlForTests(): string { + return `file:${join(tmpdir(), `anchor-kit-${randomUUID()}.sqlite`)}`; +} diff --git a/src/runtime/http/express-router.ts b/src/runtime/http/express-router.ts new file mode 100644 index 0000000..943b7cb --- /dev/null +++ b/src/runtime/http/express-router.ts @@ -0,0 +1,714 @@ +import { version } from '../../../package.json'; +import type { AnchorConfig } from '@/core/config.ts'; +import { ValidationError } from '@/core/errors.ts'; +import { InMemoryRateLimiter, type RateLimitRule } from '@/runtime/http/rate-limiter.ts'; +import type { DatabaseAdapter, WebhookProcessor } from '@/runtime/interfaces.ts'; +import { + Account, + Keypair, + Operation, + StrKey, + Transaction, + TransactionBuilder, +} from '@stellar/stellar-sdk'; +import jwt from 'jsonwebtoken'; +import { createHash, randomUUID } from 'node:crypto'; +import { IdempotencyUtils } from '@/utils/idempotency.ts'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +export type ExpressLikeMiddleware = ( + req: IncomingMessage, + res: ServerResponse, + next?: (error?: unknown) => void, +) => void; + +interface RouterDependencies { + config: AnchorConfig; + database: DatabaseAdapter; + webhookProcessor: WebhookProcessor; +} + +interface AuthenticatedRequestData { + account: string; +} + +interface JsonResponse { + status: number; + body: Record; +} + +interface RawBodyCarrier { + rawBody?: string; +} + +const SEP10_NONCE_OP = 'anchor_auth'; + +function sendJson(res: ServerResponse, status: number, body: Record): void { + if (!res.headersSent) { + res.statusCode = status; + res.setHeader('content-type', 'application/json'); + } + res.end(JSON.stringify(body)); +} + +function parseUrl(req: IncomingMessage): URL { + return new URL(req.url ?? '/', 'http://localhost'); +} + +function getBodyByteLength(value: string): number { + return Buffer.byteLength(value, 'utf8'); +} + +async function readRawBody(req: IncomingMessage, maxBodyBytes: number): Promise { + const reqWithRaw = req as IncomingMessage & RawBodyCarrier; + if (typeof reqWithRaw.rawBody === 'string') { + if (getBodyByteLength(reqWithRaw.rawBody) > maxBodyBytes) { + throw new ValidationError(`Request body too large. Max ${maxBodyBytes} bytes`); + } + return reqWithRaw.rawBody; + } + + const bodyFromFramework = (req as IncomingMessage & { body?: unknown }).body; + if (bodyFromFramework !== undefined) { + const serialized = + typeof bodyFromFramework === 'string' ? bodyFromFramework : JSON.stringify(bodyFromFramework); + if (getBodyByteLength(serialized) > maxBodyBytes) { + throw new ValidationError(`Request body too large. Max ${maxBodyBytes} bytes`); + } + return serialized; + } + + const chunks: Uint8Array[] = []; + let totalBytes = 0; + for await (const chunk of req) { + const chunkBuffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; + totalBytes += chunkBuffer.byteLength; + if (totalBytes > maxBodyBytes) { + throw new ValidationError(`Request body too large. Max ${maxBodyBytes} bytes`); + } + chunks.push(chunkBuffer); + } + return Buffer.concat(chunks).toString('utf8'); +} + +function jsonParseObject(rawBody: string): Record { + if (!rawBody) return {}; + + const parsed: unknown = JSON.parse(rawBody); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new ValidationError('Request JSON body must be an object'); + } + + return parsed as Record; +} + +function sha256(input: string): string { + return createHash('sha256').update(input).digest('hex'); +} + +function readBearerToken(req: IncomingMessage): string | null { + const authHeader = req.headers.authorization; + if (!authHeader) return null; + + const [scheme, token] = authHeader.split(' '); + if (scheme?.toLowerCase() !== 'bearer' || !token) { + return null; + } + + return token; +} + +function toNumber(value: unknown): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : NaN; +} + +function endpointPath(req: IncomingMessage): string { + return parseUrl(req).pathname; +} + +function extractClientIdentifier(req: IncomingMessage): string { + const forwardedFor = req.headers['x-forwarded-for']; + const leftMost = typeof forwardedFor === 'string' ? forwardedFor.split(',')[0].trim() : null; + const socketIp = req.socket?.remoteAddress; + return leftMost || socketIp || 'unknown'; +} + +function hasValidSignature(transaction: Transaction, publicKey: string): boolean { + const keypair = Keypair.fromPublicKey(publicKey); + const hash = transaction.hash(); + + for (const signature of transaction.signatures) { + try { + if (keypair.verify(hash, signature.signature())) { + return true; + } + } catch { + // skip invalid signature entries + } + } + + return false; +} + +function extractNonceFromChallenge(transaction: Transaction): string | null { + for (const operation of transaction.operations) { + if (operation.type !== 'manageData') { + continue; + } + + const manageDataOp = operation as unknown as { + name?: unknown; + value?: unknown; + }; + + const name = manageDataOp.name; + if (typeof name !== 'string' || name !== SEP10_NONCE_OP) { + continue; + } + + const value = manageDataOp.value; + if (value instanceof Buffer) { + return value.toString('utf8'); + } + + if (value instanceof Uint8Array) { + return Buffer.from(value).toString('utf8'); + } + + if (typeof value === 'string') { + return value; + } + } + + return null; +} + +export class AnchorExpressRouter { + private readonly config: AnchorConfig; + private readonly database: DatabaseAdapter; + private readonly webhookProcessor: WebhookProcessor; + private readonly sep10ServerKeypair: Keypair; + private readonly networkPassphrase: string; + private readonly maxBodyBytes: number; + private readonly rateLimiter = new InMemoryRateLimiter(); + private readonly rateRules: Record< + 'auth_challenge' | 'auth_token' | 'webhook' | 'deposit', + RateLimitRule + >; + + constructor(dependencies: RouterDependencies) { + this.config = dependencies.config; + this.database = dependencies.database; + this.webhookProcessor = dependencies.webhookProcessor; + this.sep10ServerKeypair = Keypair.fromSecret(this.config.get('security').sep10SigningKey); + this.networkPassphrase = this.config.get('network').networkPassphrase ?? ''; + this.maxBodyBytes = this.config.get('framework').http?.maxBodyBytes ?? 1024 * 1024; + + const rateLimitConfig = this.config.get('framework').rateLimit; + const windowMs = rateLimitConfig?.windowMs ?? 60000; + + this.rateRules = { + auth_challenge: { windowMs, max: rateLimitConfig?.authChallengeMax ?? 30 }, + auth_token: { windowMs, max: rateLimitConfig?.authTokenMax ?? 30 }, + webhook: { windowMs, max: rateLimitConfig?.webhookMax ?? 120 }, + deposit: { windowMs, max: rateLimitConfig?.depositMax ?? 60 }, + }; + } + + public getMiddleware(): ExpressLikeMiddleware { + return (req, res, next) => { + void this.handle(req, res).catch((error: unknown) => { + if (next) { + next(error); + return; + } + + sendJson(res, 500, { + error: 'internal_server_error', + message: 'Internal server error', + }); + }); + }; + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + const path = endpointPath(req); + const method = (req.method ?? 'GET').toUpperCase(); + + if (method === 'GET' && path === '/health') { + sendJson(res, 200, { status: 'ok' }); + return; + } + + if (method === 'GET' && path === '/info') { + const fullConfig = this.config.getConfig(); + const responseBody: Record = { + name: fullConfig.operational?.name ?? 'Anchor-Kit Anchor', + network: fullConfig.network.network, + assets: fullConfig.assets.assets, + version, + }; + + if (fullConfig.server.interactiveDomain) { + responseBody.interactive_domain = fullConfig.server.interactiveDomain; + } + + if (fullConfig.operational?.supportEmail) { + responseBody.support_email = fullConfig.operational.supportEmail; + } + + sendJson(res, 200, responseBody); + return; + } + + if (method === 'GET' && path === '/auth/challenge') { + if (!this.checkRateLimit(req, res, 'auth_challenge')) { + return; + } + + const account = parseUrl(req).searchParams.get('account'); + if (!account) { + sendJson(res, 400, { + error: 'invalid_request', + message: 'Query param account is required', + }); + return; + } + + if (!StrKey.isValidEd25519PublicKey(account)) { + sendJson(res, 400, { + error: 'invalid_request', + message: 'account must be a valid Stellar public key', + }); + return; + } + + const nonce = randomUUID(); + const now = Math.floor(Date.now() / 1000); + const expirationSeconds = this.config.get('security').challengeExpirationSeconds ?? 300; + const expiresAtUnix = now + expirationSeconds; + + const challengeTx = new TransactionBuilder( + new Account(this.sep10ServerKeypair.publicKey(), '0'), + { + fee: '100', + networkPassphrase: this.networkPassphrase, + }, + ) + .addOperation( + Operation.manageData({ + name: SEP10_NONCE_OP, + value: nonce, + source: account, + }), + ) + .setTimebounds(now, expiresAtUnix) + .build(); + + challengeTx.sign(this.sep10ServerKeypair); + const challengeXdr = challengeTx.toXDR(); + const expiresAt = new Date(expiresAtUnix * 1000).toISOString(); + + await this.database.insertAuthChallenge({ + id: randomUUID(), + account, + challenge: nonce, + expiresAt, + }); + + res.setHeader('Cache-Control', 'no-store'); + sendJson(res, 200, { + challenge: challengeXdr, + network_passphrase: this.networkPassphrase, + expires_at: expiresAt, + }); + return; + } + + if (method === 'POST' && path === '/auth/token') { + if (!this.checkRateLimit(req, res, 'auth_token')) { + return; + } + + const rawBody = await readRawBody(req, this.maxBodyBytes); + const body = jsonParseObject(rawBody); + const account = typeof body.account === 'string' ? body.account : ''; + const signedChallenge = typeof body.challenge === 'string' ? body.challenge : ''; + + if (!account || !signedChallenge) { + sendJson(res, 400, { + error: 'invalid_request', + message: 'Body must include account and challenge', + }); + return; + } + + if (!StrKey.isValidEd25519PublicKey(account)) { + sendJson(res, 400, { + error: 'invalid_request', + message: 'account must be a valid Stellar public key', + }); + return; + } + + let transaction: Transaction; + try { + transaction = new Transaction(signedChallenge, this.networkPassphrase); + } catch { + sendJson(res, 401, { + error: 'invalid_challenge', + message: 'Challenge transaction is invalid', + }); + return; + } + + if (transaction.source !== this.sep10ServerKeypair.publicKey()) { + sendJson(res, 401, { + error: 'invalid_challenge', + message: 'Challenge source account mismatch', + }); + return; + } + + const nonce = extractNonceFromChallenge(transaction); + if (!nonce) { + sendJson(res, 401, { + error: 'invalid_challenge', + message: 'Challenge nonce missing', + }); + return; + } + + if (!hasValidSignature(transaction, this.sep10ServerKeypair.publicKey())) { + sendJson(res, 401, { + error: 'invalid_challenge', + message: 'Challenge is missing anchor signature', + }); + return; + } + + if (!hasValidSignature(transaction, account)) { + sendJson(res, 401, { + error: 'invalid_challenge', + message: 'Challenge is missing account signature', + }); + return; + } + + const stored = await this.database.getAuthChallengeByChallenge(nonce); + if (!stored || stored.account !== account) { + sendJson(res, 401, { error: 'invalid_challenge', message: 'Challenge not found' }); + return; + } + + if (stored.consumedAt) { + sendJson(res, 401, { error: 'invalid_challenge', message: 'Challenge already used' }); + return; + } + + if (new Date(stored.expiresAt).getTime() < Date.now()) { + sendJson(res, 401, { error: 'invalid_challenge', message: 'Challenge expired' }); + return; + } + + await this.database.markAuthChallengeConsumed(stored.id); + + const tokenLifetime = this.config.get('security').authTokenLifetimeSeconds ?? 3600; + + const token = jwt.sign( + { + sub: account, + scope: 'anchor_api', + typ: 'access_token', + }, + this.config.get('security').interactiveJwtSecret, + { expiresIn: tokenLifetime }, + ); + + res.setHeader('Cache-Control', 'no-store'); + sendJson(res, 200, { + token, + expires_in: tokenLifetime, + token_type: 'Bearer', + }); + return; + } + + if (method === 'POST' && path === '/transactions/deposit/interactive') { + if (!this.checkRateLimit(req, res, 'deposit')) { + return; + } + + const auth = this.authenticate(req); + if (!auth) { + sendJson(res, 401, { error: 'unauthorized', message: 'Missing or invalid bearer token' }); + return; + } + + const rawBody = await readRawBody(req, this.maxBodyBytes); + const body = jsonParseObject(rawBody); + const assetCode = typeof body.asset_code === 'string' ? body.asset_code : ''; + const amountRaw = body.amount; + const amount = + typeof amountRaw === 'string' || typeof amountRaw === 'number' ? `${amountRaw}` : ''; + + if (!assetCode || !amount) { + sendJson(res, 400, { + error: 'invalid_request', + message: 'Body must include asset_code and amount', + }); + return; + } + + const selectedAsset = this.config.getAsset(assetCode); + if (!selectedAsset || selectedAsset.deposits_enabled === false) { + sendJson(res, 400, { error: 'invalid_asset', message: 'Unsupported or disabled asset' }); + return; + } + + const numericAmount = toNumber(amount); + if (!Number.isFinite(numericAmount) || numericAmount <= 0) { + sendJson(res, 400, { + error: 'invalid_amount', + message: 'Amount must be a positive number', + }); + return; + } + + if (selectedAsset.max_amount !== undefined && numericAmount > selectedAsset.max_amount) { + sendJson(res, 400, { + error: 'invalid_amount', + message: `Amount exceeds the maximum allowed of ${selectedAsset.max_amount}`, + max_amount: selectedAsset.max_amount, + }); + return; + } + + if (selectedAsset.min_amount !== undefined && numericAmount < selectedAsset.min_amount) { + sendJson(res, 400, { + error: 'invalid_amount', + message: `Amount is below the minimum allowed of ${selectedAsset.min_amount}`, + min_amount: selectedAsset.min_amount, + }); + return; + } + + const idempotencyKey = IdempotencyUtils.extractIdempotencyHeader( + req.headers, + 'idempotency-key', + ); + const scope = `deposit:${auth.account}`; + const requestHash = sha256(JSON.stringify({ assetCode, amount })); + + if (idempotencyKey !== null) { + const existing = await this.database.getIdempotencyRecord(scope, idempotencyKey); + if (existing) { + if (existing.requestHash !== requestHash) { + sendJson(res, 409, { + error: 'idempotency_conflict', + message: 'Idempotency key was already used with a different request body', + }); + return; + } + + sendJson(res, existing.statusCode, { + ...(JSON.parse(existing.responseBody) as Record), + idempotency_replay: true, + }); + return; + } + } + + // interactiveDomain must be configured for interactive flows + const serverConfig = this.config.get('server'); + if (!serverConfig.interactiveDomain) { + sendJson(res, 500, { + error: 'server_misconfigured', + message: 'server.interactiveDomain must be configured for interactive flows', + }); + return; + } + + const transactionId = randomUUID(); + const created = await this.database.insertInteractiveTransaction({ + id: transactionId, + account: auth.account, + kind: 'deposit', + assetCode, + amount, + status: 'pending_user_transfer_start', + }); + + const response: JsonResponse = { + status: 201, + body: { + id: created.id, + kind: created.kind, + status: created.status, + amount: created.amount, + asset_code: created.assetCode, + asset_issuer: selectedAsset.issuer, + interactive_url: `${serverConfig.interactiveDomain}/deposit/${created.id}`, + created_at: created.createdAt, + }, + }; + + if (typeof idempotencyKey === 'string' && idempotencyKey.length > 0) { + await this.database.insertIdempotencyRecord({ + id: randomUUID(), + scope, + idempotencyKey, + requestHash, + statusCode: response.status, + responseBody: JSON.stringify(response.body), + }); + } + + sendJson(res, response.status, response.body); + return; + } + + const transactionMatch = /^\/transactions\/([^/]+)$/.exec(path); + if (method === 'GET' && transactionMatch) { + const auth = this.authenticate(req); + if (!auth) { + sendJson(res, 401, { error: 'unauthorized', message: 'Missing or invalid bearer token' }); + return; + } + + const transactionId = decodeURIComponent(transactionMatch[1]); + const transaction = await this.database.getInteractiveTransactionById(transactionId); + + if (!transaction) { + sendJson(res, 404, { error: 'not_found', message: 'Transaction not found' }); + return; + } + + if (transaction.account !== auth.account) { + sendJson(res, 403, { + error: 'forbidden', + message: 'Transaction belongs to another account', + }); + return; + } + + const serverConfig = this.config.get('server'); + const selectedAsset = this.config.getAsset(transaction.assetCode); + const responseData: Record & { more_info_url?: string } = { + id: transaction.id, + kind: transaction.kind, + status: transaction.status, + amount: transaction.amount, + asset_code: transaction.assetCode, + asset_issuer: selectedAsset?.issuer, + account: transaction.account, + // interactive_url is only returned when an interactiveDomain is configured + ...(serverConfig.interactiveDomain + ? { interactive_url: `${serverConfig.interactiveDomain}/deposit/${transaction.id}` } + : {}), + created_at: transaction.createdAt, + updated_at: transaction.updatedAt, + }; + + // Add more_info_url only when interactive domain is configured + if (serverConfig.interactiveDomain) { + responseData.more_info_url = `${serverConfig.interactiveDomain}/deposit/${transaction.id}`; + } + + sendJson(res, 200, responseData); + return; + } + + if (method === 'POST' && path === '/webhooks/events') { + if (!this.checkRateLimit(req, res, 'webhook')) { + return; + } + + const rawBody = await readRawBody(req, this.maxBodyBytes); + const payload = jsonParseObject(rawBody); + const eventIdField = payload.id; + const eventId = + typeof eventIdField === 'string' && eventIdField.length > 0 ? eventIdField : randomUUID(); + const providerHeader = req.headers['x-webhook-provider']; + const providerBody = payload.provider; + const provider = + typeof providerHeader === 'string' && providerHeader.length > 0 + ? providerHeader + : typeof providerBody === 'string' && providerBody.length > 0 + ? providerBody + : 'generic'; + const signatureHeader = req.headers['x-anchor-signature']; + const signature = typeof signatureHeader === 'string' ? signatureHeader : undefined; + + try { + const result = await this.webhookProcessor.process({ + eventId, + provider, + payload, + rawBody, + signature, + }); + + sendJson(res, 200, { + received: true, + duplicate: result.duplicate, + event_id: result.eventId, + received_at: new Date().toISOString(), + provider, + }); + } catch { + sendJson(res, 400, { + error: 'webhook_error', + message: 'Webhook processing failed', + }); + } + return; + } + + sendJson(res, 404, { error: 'not_found', message: 'Endpoint not found' }); + } + + private checkRateLimit( + req: IncomingMessage, + res: ServerResponse, + endpoint: 'auth_challenge' | 'auth_token' | 'webhook' | 'deposit', + ): boolean { + const clientId = extractClientIdentifier(req); + const key = `${endpoint}:${clientId}`; + const result = this.rateLimiter.hit(key, this.rateRules[endpoint]); + + if (!result.allowed) { + res.setHeader('retry-after', `${result.retryAfterSeconds}`); + sendJson(res, 429, { + error: 'rate_limited', + message: 'Too many requests', + }); + return false; + } + + return true; + } + + private authenticate(req: IncomingMessage): AuthenticatedRequestData | null { + const token = readBearerToken(req); + if (!token) return null; + + try { + const decoded = jwt.verify( + token, + this.config.get('security').interactiveJwtSecret, + ) as jwt.JwtPayload; + const account = typeof decoded.sub === 'string' ? decoded.sub : null; + const scope = typeof decoded.scope === 'string' ? decoded.scope : null; + const typ = typeof decoded.typ === 'string' ? decoded.typ : null; + if (!account || scope !== 'anchor_api' || typ !== 'access_token') { + return null; + } + + return { account }; + } catch { + return null; + } + } +} diff --git a/src/runtime/http/rate-limiter.ts b/src/runtime/http/rate-limiter.ts new file mode 100644 index 0000000..a749067 --- /dev/null +++ b/src/runtime/http/rate-limiter.ts @@ -0,0 +1,39 @@ +interface RateLimitBucket { + count: number; + resetAt: number; +} + +export interface RateLimitRule { + windowMs: number; + max: number; +} + +export class InMemoryRateLimiter { + private readonly buckets = new Map(); + + public hit(key: string, rule: RateLimitRule): { allowed: boolean; retryAfterSeconds: number } { + const now = Date.now(); + const bucket = this.buckets.get(key); + + if (!bucket || now >= bucket.resetAt) { + this.buckets.set(key, { + count: 1, + resetAt: now + rule.windowMs, + }); + return { allowed: true, retryAfterSeconds: Math.ceil(rule.windowMs / 1000) }; + } + + bucket.count += 1; + if (bucket.count > rule.max) { + return { + allowed: false, + retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)), + }; + } + + return { + allowed: true, + retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)), + }; + } +} diff --git a/src/runtime/interfaces.ts b/src/runtime/interfaces.ts new file mode 100644 index 0000000..718e3ff --- /dev/null +++ b/src/runtime/interfaces.ts @@ -0,0 +1,140 @@ +export interface AuthChallengeRecord { + id: string; + account: string; + challenge: string; + expiresAt: string; + consumedAt: string | null; + createdAt: string; +} + +export interface InteractiveTransactionRecord { + id: string; + account: string; + kind: 'deposit'; + assetCode: string; + amount: string; + status: string; + createdAt: string; + updatedAt: string; +} + +export interface IdempotencyRecord { + id: string; + scope: string; + idempotencyKey: string; + requestHash: string; + statusCode: number; + responseBody: string; + createdAt: string; +} + +export interface WebhookEventRecord { + id: string; + eventId: string; + provider: string; + payload: Record; + status: 'pending' | 'processed' | 'failed'; + errorMessage: string | null; + processedAt: string | null; + createdAt: string; +} + +export interface WatcherTaskRecord { + id: string; + watcherName: string; + payload: Record; + status: 'pending' | 'processed' | 'failed'; + errorMessage: string | null; + processedAt: string | null; + createdAt: string; +} + +export interface DatabaseAdapter { + connect(): Promise; + disconnect(): Promise; + migrate(): Promise; + + insertAuthChallenge(input: { + id: string; + account: string; + challenge: string; + expiresAt: string; + }): Promise; + getAuthChallengeByChallenge(challenge: string): Promise; + markAuthChallengeConsumed(id: string): Promise; + + insertInteractiveTransaction(input: { + id: string; + account: string; + kind: 'deposit'; + assetCode: string; + amount: string; + status: string; + }): Promise; + getInteractiveTransactionById(id: string): Promise; + listPendingTransactionsBefore(cutoffIso: string): Promise; + updateTransactionStatus(id: string, status: string): Promise; + + getIdempotencyRecord(scope: string, idempotencyKey: string): Promise; + insertIdempotencyRecord(input: { + id: string; + scope: string; + idempotencyKey: string; + requestHash: string; + statusCode: number; + responseBody: string; + }): Promise; + + insertWebhookEvent(input: { + id: string; + eventId: string; + provider: string; + payload: Record; + }): Promise<{ record: WebhookEventRecord; inserted: boolean }>; + updateWebhookEventStatus(input: { + id: string; + status: 'processed' | 'failed'; + errorMessage?: string; + }): Promise; + + insertWatcherTask(input: { + id: string; + watcherName: string; + payload: Record; + }): Promise; + listPendingWatcherTasks(limit: number): Promise; + updateWatcherTaskStatus(input: { + id: string; + status: 'processed' | 'failed'; + errorMessage?: string; + }): Promise; + countProcessedWatcherTasks(): Promise; + cleanupOldRecords(cutoffIso: string): Promise; +} + +export interface QueueJob { + type: 'expire_transaction' | 'process_watcher_task' | 'cleanup_records'; + payload: Record; +} + +export interface QueueAdapter { + enqueue(job: QueueJob): Promise; + start(worker: (job: QueueJob) => Promise): Promise; + stop(): Promise; +} + +export interface Watcher { + readonly name: string; + start(): Promise; + stop(): Promise; +} + +export interface WebhookProcessor { + process(input: { + eventId: string; + provider: string; + payload: Record; + rawBody: string; + signature?: string; + }): Promise<{ duplicate: boolean; eventId: string }>; +} diff --git a/src/runtime/queue/in-memory-queue.ts b/src/runtime/queue/in-memory-queue.ts new file mode 100644 index 0000000..6fa977b --- /dev/null +++ b/src/runtime/queue/in-memory-queue.ts @@ -0,0 +1,87 @@ +import type { QueueAdapter, QueueJob } from '@/runtime/interfaces.ts'; + +interface InMemoryQueueOptions { + concurrency: number; +} + +export class InMemoryQueueAdapter implements QueueAdapter { + private readonly concurrency: number; + private readonly jobs: QueueJob[] = []; + private running = false; + private activeWorkers = 0; + private worker: ((job: QueueJob) => Promise) | null = null; + private stopPromise: Promise | null = null; + private resolveStop: (() => void) | null = null; + + constructor(options: InMemoryQueueOptions) { + this.concurrency = options.concurrency; + } + + public async enqueue(job: QueueJob): Promise { + this.jobs.push(job); + this.kick(); + } + + public async start(worker: (job: QueueJob) => Promise): Promise { + this.worker = worker; + this.running = true; + this.kick(); + } + + public async stop(): Promise { + this.running = false; + + if (this.activeWorkers === 0) { + return; + } + + if (this.stopPromise) { + return this.stopPromise; + } + + this.stopPromise = new Promise((resolve) => { + this.resolveStop = resolve; + }); + + // In case activeWorkers reached 0 between our check and creating the promise + if (this.activeWorkers === 0) { + this.resolveStop?.(); + this.resolveStop = null; + this.stopPromise = null; + } + + return this.stopPromise || Promise.resolve(); + } + + private kick(): void { + if (!this.running || !this.worker) return; + + while (this.activeWorkers < this.concurrency && this.jobs.length > 0) { + if (!this.running) break; + + const job = this.jobs.shift(); + if (!job) break; + + this.activeWorkers += 1; + const worker = this.worker; + + (async () => { + try { + await worker(job); + } catch { + // Best-effort queue for MVP: job errors are handled by worker logic. + } finally { + this.activeWorkers -= 1; + + if (!this.running && this.activeWorkers === 0 && this.resolveStop) { + this.resolveStop(); + this.resolveStop = null; + this.stopPromise = null; + } + + this.kick(); + } + })(); + } + } +} diff --git a/src/runtime/watchers/transaction-watcher.ts b/src/runtime/watchers/transaction-watcher.ts new file mode 100644 index 0000000..e6cb0dd --- /dev/null +++ b/src/runtime/watchers/transaction-watcher.ts @@ -0,0 +1,87 @@ +import { randomUUID } from 'node:crypto'; +import type { DatabaseAdapter, QueueAdapter, Watcher } from '@/runtime/interfaces.ts'; + +interface TransactionWatcherOptions { + pollIntervalMs: number; + transactionTimeoutMs: number; + retentionDays: number; +} + +export class TransactionWatcher implements Watcher { + public readonly name = 'transaction-watcher'; + + private readonly database: DatabaseAdapter; + private readonly queue: QueueAdapter; + private readonly pollIntervalMs: number; + private readonly transactionTimeoutMs: number; + private readonly retentionDays: number; + private isTickInProgress = false; + private timer: ReturnType | null = null; + + constructor(database: DatabaseAdapter, queue: QueueAdapter, options: TransactionWatcherOptions) { + this.database = database; + this.queue = queue; + this.pollIntervalMs = options.pollIntervalMs; + this.transactionTimeoutMs = options.transactionTimeoutMs; + this.retentionDays = options.retentionDays; + } + + public async start(): Promise { + if (this.timer) return; + + await this.tick(); + this.timer = setInterval(() => { + void this.tick(); + }, this.pollIntervalMs); + } + + public async stop(): Promise { + if (!this.timer) return; + clearInterval(this.timer); + this.timer = null; + } + + private async tick(): Promise { + if (this.isTickInProgress) { + return; + } + + this.isTickInProgress = true; + + try { + const cutoff = new Date(Date.now() - this.transactionTimeoutMs).toISOString(); + const pendingTransactions = await this.database.listPendingTransactionsBefore(cutoff); + + for (const transaction of pendingTransactions) { + await this.queue.enqueue({ + type: 'expire_transaction', + payload: { transactionId: transaction.id }, + }); + } + + const watcherTaskId = randomUUID(); + await this.database.insertWatcherTask({ + id: watcherTaskId, + watcherName: this.name, + payload: { + pendingTransactionsChecked: pendingTransactions.length, + checkedAt: new Date().toISOString(), + }, + }); + + await this.queue.enqueue({ + type: 'process_watcher_task', + payload: { watcherTaskId }, + }); + + await this.queue.enqueue({ + type: 'cleanup_records', + payload: { + retentionDays: this.retentionDays, + }, + }); + } finally { + this.isTickInProgress = false; + } + } +} diff --git a/src/runtime/webhooks/default-webhook-processor.ts b/src/runtime/webhooks/default-webhook-processor.ts new file mode 100644 index 0000000..149abc0 --- /dev/null +++ b/src/runtime/webhooks/default-webhook-processor.ts @@ -0,0 +1,113 @@ +import { createHmac, timingSafeEqual, randomUUID } from 'node:crypto'; +import type { AnchorKitConfig } from '@/types/config.ts'; +import type { DatabaseAdapter, WebhookProcessor } from '@/runtime/interfaces.ts'; + +interface DefaultWebhookProcessorOptions { + config: AnchorKitConfig; + database: DatabaseAdapter; +} + +function toComparableBuffer(value: string): Buffer { + return Buffer.from(value, 'utf8'); +} + +function safeEquals(left: string, right: string): boolean { + const leftBuffer = toComparableBuffer(left); + const rightBuffer = toComparableBuffer(right); + + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + + return timingSafeEqual(leftBuffer, rightBuffer); +} + +export class DefaultWebhookProcessor implements WebhookProcessor { + private readonly config: AnchorKitConfig; + private readonly database: DatabaseAdapter; + + constructor(options: DefaultWebhookProcessorOptions) { + this.config = options.config; + this.database = options.database; + } + + public async process(input: { + eventId: string; + provider: string; + payload: Record; + rawBody: string; + signature?: string; + }): Promise<{ duplicate: boolean; eventId: string }> { + this.verifySignatureIfEnabled(input); + + const insertion = await this.database.insertWebhookEvent({ + id: randomUUID(), + eventId: input.eventId, + provider: input.provider, + payload: input.payload, + }); + + if (!insertion.inserted) { + return { duplicate: true, eventId: insertion.record.eventId }; + } + + try { + await this.config.webhooks?.onEvent?.( + { + id: insertion.record.id, + eventId: insertion.record.eventId, + provider: insertion.record.provider, + payload: insertion.record.payload, + }, + { + receivedAt: insertion.record.createdAt, + signature: input.signature, + }, + ); + + await this.database.updateWebhookEventStatus({ + id: insertion.record.id, + status: 'processed', + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown webhook callback error'; + await this.database.updateWebhookEventStatus({ + id: insertion.record.id, + status: 'failed', + errorMessage: message, + }); + throw error; + } + + return { duplicate: false, eventId: insertion.record.eventId }; + } + + private verifySignatureIfEnabled(input: { + payload: Record; + rawBody: string; + signature?: string; + }): void { + const verifyEnabled = this.config.security.verifyWebhookSignatures ?? true; + + if (!verifyEnabled) { + return; + } + + const webhookSecret = this.config.security.webhookSecret; + if (!webhookSecret) { + throw new Error( + 'Webhook signature verification is enabled but no webhook secret is configured', + ); + } + + if (!input.signature) { + throw new Error('Missing webhook signature'); + } + + const expected = createHmac('sha256', webhookSecret).update(input.rawBody).digest('hex'); + + if (!safeEquals(expected, input.signature)) { + throw new Error('Invalid webhook signature'); + } + } +} diff --git a/src/services/README.md b/src/services/README.md new file mode 100644 index 0000000..a48756c --- /dev/null +++ b/src/services/README.md @@ -0,0 +1,5 @@ +# Planned Services Stub + +This directory is reserved for runtime orchestration services (watchers, webhook processors, queues, etc.). + +No service implementations are shipped yet. diff --git a/src/types/config.ts b/src/types/config.ts index d6fd376..a326277 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -141,6 +141,12 @@ export interface SecurityConfig { * @optional - defaults to true */ verifyWebhookSignatures?: boolean; + + /** + * Auth token lifetime in seconds + * @optional - defaults to 3600 (1 hour) + */ + authTokenLifetimeSeconds?: number; } /** @@ -399,6 +405,101 @@ export interface FrameworkConfig { schema?: string; }; + /** + * Queue backend configuration + * @optional - defaults to in-process memory queue + */ + queue?: { + /** + * Queue backend implementation + */ + backend: 'memory'; + + /** + * Number of worker tasks processed concurrently + * @optional - defaults to 1 + */ + concurrency?: number; + }; + + /** + * Watcher configuration for async lifecycle checks + * @optional + */ + watchers?: { + /** + * Enable periodic watcher checks + * @optional - defaults to true + */ + enabled?: boolean; + + /** + * Poll interval in milliseconds + * @optional - defaults to 15000 + */ + pollIntervalMs?: number; + + /** + * Pending transaction timeout in milliseconds + * @optional - defaults to 300000 + */ + transactionTimeoutMs?: number; + + /** + * Retention window in days for watcher logs and operational records. + * @optional - defaults to 90 + */ + retentionDays?: number; + }; + + /** + * HTTP guardrails for SDK route handlers. + * @optional + */ + http?: { + /** + * Maximum accepted request body size in bytes. + * @optional - defaults to 1048576 (1 MB) + */ + maxBodyBytes?: number; + }; + + /** + * In-process per-route rate limiting. + * @optional + */ + rateLimit?: { + /** + * Sliding window duration in milliseconds. + * @optional - defaults to 60000 + */ + windowMs?: number; + + /** + * Max requests per window for auth challenge endpoint. + * @optional - defaults to 30 + */ + authChallengeMax?: number; + + /** + * Max requests per window for auth token endpoint. + * @optional - defaults to 30 + */ + authTokenMax?: number; + + /** + * Max requests per window for webhook endpoint. + * @optional - defaults to 120 + */ + webhookMax?: number; + + /** + * Max requests per window for deposit endpoint. + * @optional - defaults to 60 + */ + depositMax?: number; + }; + /** * Plugin system for extending functionality * @optional @@ -517,4 +618,25 @@ export interface AnchorKitConfig { * @required */ framework: FrameworkConfig; + + /** + * Webhook integration configuration. + */ + webhooks?: { + /** + * Called after webhook event verification and persistence. + */ + onEvent?: ( + event: { + id: string; + eventId: string; + provider: string; + payload: Record; + }, + context: { + receivedAt: string; + signature?: string; + }, + ) => Promise | void; + }; } diff --git a/src/types/foundation.ts b/src/types/foundation.ts index 6499817..68b9f7b 100644 --- a/src/types/foundation.ts +++ b/src/types/foundation.ts @@ -55,13 +55,33 @@ export interface KycData { export type { KycData as CustomerKycData }; /** - * Error returned when a transaction cannot be found or accessed. - * Included in SEP-24 transaction responses as an error branch. + * Common error codes used across Stellar Ecosystem Proposals (SEPs). */ -export interface TransactionNotFoundError { - /** Discriminator to allow narrowing on error responses */ - type: 'error'; +export type SepErrorCode = + | 'bad_request' + | 'transaction_not_found' + | 'customer_info_needed' + | 'verification_required' + | 'not_found' + | 'invalid_asset' + | 'unsupported_asset' + | 'invalid_request' + | 'forbidden' + | string; - /** Human readable error message */ - error: string; +/** + * RouteDefinition - Defines an API route to be injected by a plugin. + */ +export interface RouteDefinition { + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + path: string; + handler: (ctx: unknown) => Promise | unknown; +} + +/** + * SchemaDefinition - Defines database schema extensions for a plugin. + */ +export interface SchemaDefinition { + name: string; + tables: unknown[]; } diff --git a/src/types/index.ts b/src/types/index.ts index ee48d61..4cd2626 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,13 +3,23 @@ * This is the main entry point for all type exports */ -export { TRANSACTION_STATUSES } from './transaction-status.ts'; -export type { TransactionStatus } from './transaction-status.ts'; +export { + TRANSACTION_STATUSES, + isPendingTransactionStatus, + isTerminalTransactionStatus, + isTransactionStatus, +} from './transaction-status.ts'; +export type { + PendingTransactionStatus, + TerminalTransactionStatus, + TransactionStatus, +} from './transaction-status.ts'; export type { Customer } from './customer.ts'; export type { Transaction, + TransactionKind, Amount, RailTransactionData, StellarTransactionData, @@ -20,4 +30,14 @@ export type { export * from './config'; export * from './sep24'; -export * from './foundation'; +export type { KycStatus } from './foundation'; +export type { PostalAddress } from './foundation'; +export type { IdentityDocument } from './foundation'; +export type { KycData, KycData as CustomerKycData } from './foundation'; +export type { + AnchorPlugin, + AnchorPluginContext, + AnchorPluginHooks, + Context as PluginContext, +} from './plugin'; +export type { RouteDefinition, SchemaDefinition, SepErrorCode } from './foundation'; diff --git a/src/types/plugin.ts b/src/types/plugin.ts new file mode 100644 index 0000000..d3ad198 --- /dev/null +++ b/src/types/plugin.ts @@ -0,0 +1,50 @@ +import { RouteDefinition, SchemaDefinition } from './foundation'; + +export interface AnchorPluginContext< + TConfig = unknown, + TDb = unknown, + TBody = unknown, + TParams extends Record = Record, + TQuery extends Record = Record, +> { + config: TConfig; + db: TDb; + params: TParams; + query: TQuery; + body: TBody; +} + +export type Context = AnchorPluginContext; + +export interface AnchorPluginHooks { + onDepositRequest?: (ctx: Context) => Promise; + onWithdrawalRequest?: (ctx: Context) => Promise; + onSep10Challenge?: (tx: unknown) => Promise; + onTransactionStatusChange?: (tx: unknown, oldStatus: string, newStatus: string) => Promise; +} + +export interface AnchorPlugin { + id: string; + name?: string; + version?: string; + + /** + * Inject API routes into the main server instance + */ + routes?: RouteDefinition[]; + + /** + * Extend the database schema context + */ + schema?: SchemaDefinition; + + /** + * Hook into the transaction lifecycle + */ + hooks?: AnchorPluginHooks; + + /** + * Plugin initialization lifecycle + */ + init?: (instance: unknown) => Promise | void; +} diff --git a/src/types/sep24/common.ts b/src/types/sep24/common.ts index ee1fb60..2cca87b 100644 --- a/src/types/sep24/common.ts +++ b/src/types/sep24/common.ts @@ -71,3 +71,51 @@ export interface BaseTransactionResponse { }>; }; } + +/** + * TransactionNotFoundError represents the response when a transaction is not found + * + * This type is used in the Sep24TransactionResponse union to handle cases where + * a requested transaction does not exist. + * + * @example + * ```typescript + * const response: Sep24TransactionResponse = getTransaction(id); + * if (response.type === 'not_found') { + * console.log('Transaction not found:', response.error); + * } + * ``` + */ +export interface TransactionNotFoundError { + /** Discriminator field indicating this is a not found error response */ + type: 'not_found'; + /** Error message describing the not found error */ + error: string; +} + +/** + * Type guard to narrow a generic Sep24TransactionResponse to TransactionNotFoundError + * + * @param response - Response to check + * @returns True if the response is a transaction not found error + * + * @example + * ```typescript + * const response = await getTransaction(id); + * if (isTransactionNotFoundError(response)) { + * console.log('Error:', response.error); + * } + * ``` + */ +export function isTransactionNotFoundError( + response: unknown, +): response is TransactionNotFoundError { + return ( + typeof response === 'object' && + response !== null && + 'type' in response && + response.type === 'not_found' && + 'error' in response && + typeof (response as Record).error === 'string' + ); +} diff --git a/src/types/sep24/index.ts b/src/types/sep24/index.ts index 5fcf7a6..0d2c3db 100644 --- a/src/types/sep24/index.ts +++ b/src/types/sep24/index.ts @@ -4,6 +4,8 @@ */ export type { BaseTransactionResponse } from './common'; +export type { TransactionNotFoundError } from './common'; +export { isTransactionNotFoundError } from './common'; export type { DepositTransaction } from './deposits'; export { isDepositTransaction } from './deposits'; @@ -13,7 +15,7 @@ export { isWithdrawalTransaction } from './withdrawals'; import type { DepositTransaction } from './deposits'; import type { WithdrawalTransaction } from './withdrawals'; -import type { TransactionNotFoundError } from '../foundation'; +import type { TransactionNotFoundError } from './common'; export type Sep24TransactionResponse = | DepositTransaction diff --git a/src/types/transaction-status.ts b/src/types/transaction-status.ts index ccd3839..4879aaf 100644 --- a/src/types/transaction-status.ts +++ b/src/types/transaction-status.ts @@ -26,3 +26,67 @@ export const TRANSACTION_STATUSES = [ /** Union of all valid transaction statuses, pulled from the array above. */ export type TransactionStatus = (typeof TRANSACTION_STATUSES)[number]; + +const TERMINAL_TRANSACTION_STATUSES = [ + 'completed', + 'refunded', + 'expired', + 'error', + 'no_market', + 'too_small', + 'too_large', +] as const; + +/** Union of transaction statuses that cannot make further progress. */ +export type TerminalTransactionStatus = (typeof TERMINAL_TRANSACTION_STATUSES)[number]; + +/** + * Runtime guard that validates arbitrary input is a `TransactionStatus`. + * Uses the canonical `TRANSACTION_STATUSES` array as the single source of truth. + */ +export function isTransactionStatus(value: unknown): value is TransactionStatus { + return typeof value === 'string' && (TRANSACTION_STATUSES as readonly string[]).includes(value); +} + +/** + * Returns true when a transaction can no longer make progress. + * + * Terminal statuses: + * `completed`, `refunded`, `expired`, `error`, `no_market`, `too_small`, `too_large` + */ +export function isTerminalTransactionStatus( + status: TransactionStatus, +): status is TerminalTransactionStatus { + return (TERMINAL_TRANSACTION_STATUSES as readonly TransactionStatus[]).includes(status); +} + +/** Union of all pending transaction statuses. */ +export type PendingTransactionStatus = Extract; + +const PENDING_TRANSACTION_STATUSES: ReadonlySet = new Set([ + 'pending_anchor', + 'pending_user_transfer_start', + 'pending_user_transfer_complete', + 'pending_external', + 'pending_trust', + 'pending_user', + 'pending_stellar', +]); + +/** + * Returns whether a transaction is still in progress. + * + * Pending statuses are: + * - `pending_anchor` + * - `pending_user_transfer_start` + * - `pending_user_transfer_complete` + * - `pending_external` + * - `pending_trust` + * - `pending_user` + * - `pending_stellar` + */ +export function isPendingTransactionStatus( + status: TransactionStatus, +): status is PendingTransactionStatus { + return PENDING_TRANSACTION_STATUSES.has(status); +} diff --git a/src/types/transaction.ts b/src/types/transaction.ts index f3b55b2..c591ada 100644 --- a/src/types/transaction.ts +++ b/src/types/transaction.ts @@ -8,6 +8,8 @@ import type { TransactionStatus } from './transaction-status.ts'; +export type TransactionKind = 'deposit' | 'withdrawal'; + /** * Represents a monetary amount with currency/asset information * Uses string for decimal precision (common pattern for financial data) @@ -140,7 +142,7 @@ export interface Transaction { status: TransactionStatus; /** Transaction type: 'deposit' (fiat->crypto) or 'withdrawal' (crypto->fiat) */ - kind: 'deposit' | 'withdrawal'; + kind: TransactionKind; // ============================================ // Amount Fields (using Decimal string representation) diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts new file mode 100644 index 0000000..ceeb48d --- /dev/null +++ b/src/utils/crypto.ts @@ -0,0 +1,83 @@ +import * as jose from 'jose'; +import type { JWTPayload } from 'jose'; +import bcrypt from 'bcryptjs'; + +/** + * Utility class for cryptographic operations. + */ +export const CryptoUtils = { + /** + * Generates a cryptographically secure random string of the specified length. + * + * @param length The length of the string to generate. + * @returns A secure random string. + */ + generateRandomString(length: number): string { + const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + const randomValues = new Uint32Array(length); + crypto.getRandomValues(randomValues); + for (let i = 0; i < length; i++) { + result += charset[randomValues[i] % charset.length]; + } + return result; + }, + + /** + * Hashes a password using bcrypt. + * + * @param password The password to hash. + * @returns A promise that resolves to the hashed password. + */ + async hashPassword(password: string): Promise { + return bcrypt.hash(password, 10); + }, + + /** + * Verifies a password against a bcrypt hash. + * + * @param password The password to verify. + * @param hash The hash to verify against. + * @returns A promise that resolves to true if the password matches the hash, false otherwise. + */ + async verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); + }, + + /** + * Generates a JSON Web Token (JWT). + * + * @param payload The payload to include in the token. + * @param secret The secret key to sign the token with. + * @param options Additional options for token generation (e.g., expiration). + * @returns A promise that resolves to the generated JWT. + */ + async generateJwt( + payload: JWTPayload, + secret: string, + options: { expiresIn?: string | number } = {}, + ): Promise { + const secretKey = new TextEncoder().encode(secret); + const builder = new jose.SignJWT(payload).setProtectedHeader({ alg: 'HS256' }).setIssuedAt(); + + if (options.expiresIn) { + builder.setExpirationTime(options.expiresIn); + } + + return builder.sign(secretKey); + }, + + /** + * Verifies a JSON Web Token (JWT). + * + * @param token The token to verify. + * @param secret The secret key to verify the token with. + * @returns A promise that resolves to the decoded payload if the token is valid. + * @throws Error if the token is invalid or expired. + */ + async verifyJwt(token: string, secret: string): Promise { + const secretKey = new TextEncoder().encode(secret); + const { payload } = await jose.jwtVerify(token, secretKey); + return payload; + }, +}; diff --git a/src/utils/decimal.ts b/src/utils/decimal.ts new file mode 100644 index 0000000..7f8892b --- /dev/null +++ b/src/utils/decimal.ts @@ -0,0 +1,81 @@ +import Big from 'big.js'; +import { ValidationUtils } from './validation'; + +/** + * DecimalUtils helper object + * Provides high-precision financial math capabilities for Stellar anchor services. + * Uses big.js to preserve precision and deterministic behavior. + */ +export const DecimalUtils = { + /** + * Converts a string to a Big instance. + * + * @param value The decimal string. + * @returns Big instance. + */ + fromString(value: string): Big { + if (!ValidationUtils.isDecimal(value)) { + throw new Error(`Invalid decimal string provided: ${value}`); + } + return new Big(value); + }, + + /** + * Adds two decimal strings. + * + * @param a First operand. + * @param b Second operand. + * @returns Sum as string. + */ + add(a: string, b: string): string { + return this.fromString(a).plus(this.fromString(b)).toFixed(); + }, + + /** + * Subtracts one decimal string from another. + * + * @param a Minuend. + * @param b Subtrahend. + * @returns Difference as string. + */ + subtract(a: string, b: string): string { + return this.fromString(a).minus(this.fromString(b)).toFixed(); + }, + + /** + * Multiplies two decimal strings. + * + * @param a Multiplicand. + * @param b Multiplier. + * @returns Product as string. + */ + multiply(a: string, b: string): string { + return this.fromString(a).times(this.fromString(b)).toFixed(); + }, + + /** + * Divides one decimal string by another with optional precision control. + * + * @param a Dividend. + * @param b Divisor. + * @param precision Optional decimal places (defaults to 7 for Stellar convention). + * @returns Quotient as string. + */ + divide(a: string, b: string, precision: number = 7): string { + return this.fromString(a).div(this.fromString(b)).toFixed(precision); + }, + + /** + * Calculates an amount with a percentage fee applied. + * amount * (1 + feePercentage / 100) + * + * @param amount The base amount string. + * @param feePercentage The fee percentage (e.g., 2.5 for 2.5%). + * @returns Total amount including fee as string. + */ + applyFee(amount: string, feePercentage: number): string { + const bigAmount = this.fromString(amount); + const bigFee = new Big(feePercentage).div(100); + return bigAmount.times(new Big(1).plus(bigFee)).toFixed(); + }, +}; diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts new file mode 100644 index 0000000..423c98e --- /dev/null +++ b/src/utils/error-handler.ts @@ -0,0 +1,81 @@ +// Global error handler for AnchorKit +import { + SepProtocolError, + RailError, + AnchorKitError, + CryptoError, + NetworkError, +} from '../core/errors'; + +/** + * Global error handler for API/server responses. + * Maps AnchorKit errors to safe client/gateway responses. + * @param err - The error thrown + * @returns An object with status and safe payload + */ +export function errorHandler(err: unknown): { status: number; payload: object } { + // SEP protocol errors: safe for client + if (err instanceof SepProtocolError) { + return { + status: err.statusCode, + payload: { + error: err.errorCode, + message: err.message, + ...(err.sepErrorType && { type: err.sepErrorType }), + }, + }; + } + + // Rail errors: mask details, safe for gateway + if (err instanceof RailError) { + return { + status: err.statusCode, + payload: { + error: err.errorCode, + message: 'A gateway error occurred.', + }, + }; + } + + // Crypto errors: mask details for security + if (err instanceof CryptoError) { + return { + status: err.statusCode, + payload: { + error: err.errorCode, + message: 'A cryptographic operation failed.', + }, + }; + } + + // Network errors: mask upstream details + if (err instanceof NetworkError) { + return { + status: err.statusCode, + payload: { + error: err.errorCode, + message: 'An upstream network service is currently unavailable.', + }, + }; + } + + // Other AnchorKit errors: generic message + if (err instanceof AnchorKitError) { + return { + status: err.statusCode, + payload: { + error: err.errorCode, + message: err.message, + }, + }; + } + + // Unknown/unexpected errors: generic internal error + return { + status: 500, + payload: { + error: 'INTERNAL_SERVER_ERROR', + message: 'An internal server error occurred.', + }, + }; +} diff --git a/src/utils/idempotency.ts b/src/utils/idempotency.ts new file mode 100644 index 0000000..d1643a7 --- /dev/null +++ b/src/utils/idempotency.ts @@ -0,0 +1,60 @@ +/** + * IdempotencyUtils helper object + * Provides helpers for generating idempotency keys and extracting + * idempotency header values from different header shapes. + */ +type HeaderValue = string | string[] | undefined | null; +type HeadersRecord = Record; +type HeadersGetter = { get(name: string): string | null | undefined }; +export type HeadersLike = Headers | HeadersGetter | HeadersRecord; + +export const IdempotencyUtils = { + /** + * Generate a standardized UUID v4 idempotency key. + * + * @param prefix Optional prefix to help grouping keys + */ + generateIdempotencyKey(prefix?: string): string { + const uuid = crypto.randomUUID(); + return prefix ? `${prefix}-${uuid}` : uuid; + }, + + /** + * Extract the idempotency header value from a Headers-like object, + * plain object, or an array value. Normalizes missing or empty values + * to `null` for consistent calling code. + * + * Accepts: Fetch `Headers`, a Node/express `IncomingHttpHeaders`-like + * object, or a plain record where values may be string | string[] | undefined. + */ + extractIdempotencyHeader( + headers: HeadersLike | null | undefined, + headerName = 'Idempotency-Key', + ): string | null { + if (!headers) return null; + + // Fetch Headers instance + if (typeof headers.get === 'function') { + const v = headers.get(headerName) ?? headers.get(headerName.toLowerCase()); + if (!v) return null; + const trimmed = String(v).trim(); + return trimmed === '' ? null : trimmed; + } + + // Plain object (case-insensitive key lookup) + const headerRecord = headers as HeadersRecord; + const keys = Object.keys(headerRecord); + const foundKey = keys.find((k) => k.toLowerCase() === headerName.toLowerCase()); + if (!foundKey) return null; + + const val = headerRecord[foundKey]; + if (Array.isArray(val)) { + const found = val.map((s) => (s == null ? '' : String(s).trim())).find((s) => s.length > 0); + return found ?? null; + } + + if (val == null) return null; + const s = String(val).trim(); + return s === '' ? null : s; + }, +}; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..a390b97 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,7 @@ +export * from './validation'; +export * from './decimal'; +export * from './crypto'; +export * from './idempotency'; + +export * from './error-handler'; +export * from './stellar.ts'; diff --git a/src/utils/stellar.ts b/src/utils/stellar.ts new file mode 100644 index 0000000..b018f7e --- /dev/null +++ b/src/utils/stellar.ts @@ -0,0 +1,195 @@ +import { + Account, + Memo as StellarMemo, + TransactionBuilder, + Asset, + MuxedAccount, + Networks, + Operation, + StrKey, + Transaction, +} from '@stellar/stellar-sdk'; +import { ValidationUtils } from './validation'; + +/** + * Stellar memo types + */ +export type Memo = { + value: string; + type: 'text' | 'id' | 'hash' | 'return'; +}; + +/** + * Parsed transaction structure + */ +export interface ParsedTransaction { + source: string; + sequence: string; + fee: string; + memo?: Memo; + operations: unknown[]; +} + +/** + * Parameters for building a payment transaction + */ +export interface PaymentParams { + source: string; + destination: string; + amount: string; + assetCode: string; + issuer?: string; + memo?: Memo; + network?: 'testnet' | 'public' | 'futurenet' | string; +} + +/** + * StellarUtils helper object providing reusable utilities for common Stellar tasks. + */ +export const StellarUtils = { + /** + * Generates a Stellar memo based on the transaction ID. + * + * @param transactionId - The internal transaction ID to use as the memo value + * @param type - The memo type ('hash' or 'text') + * @returns A Memo object + */ + generateMemo(transactionId: string, type: 'hash' | 'text' = 'hash'): Memo { + if (type === 'hash') { + // For hash memo, we expect a 32-byte value. If transactionId is a UUID, + // it's 16 bytes. We keep it as is, the SDK handles string/buffer. + return { + value: transactionId, + type: 'hash', + }; + } + // Text memo is strictly limited to 28 bytes. + // If using a UUID string, we must truncate, but 28 chars of a UUID v4 + // still provides sufficient uniqueness (> 10^30 combinations). + return { + value: transactionId.substring(0, 28), + type: 'text', + }; + }, + + /** + * Parses a Base64-encoded XDR transaction. + * + * @param xdr - Base64-encoded Stellar transaction XDR + * @returns ParsedTransaction object with key details + */ + parseXdrTransaction(xdr: string): ParsedTransaction { + try { + // We don't know the network here, but for parsing core fields it might not matter + // unless we're verifying signatures. Defaulting to Testnet for parsing. + const tx = new Transaction(xdr, Networks.TESTNET); + + let memo: Memo | undefined; + if (tx.memo && tx.memo.type !== 'none') { + memo = { + value: tx.memo.value ? tx.memo.value.toString() : '', + type: tx.memo.type as 'text' | 'id' | 'hash' | 'return', + }; + } + + return { + source: tx.source, + sequence: tx.sequence, + fee: tx.fee.toString(), + memo, + operations: tx.operations, + }; + } catch (error) { + throw new Error(`Failed to parse XDR transaction: ${(error as Error).message}`, { + cause: error, + }); + } + }, + + /** + * Builds a payment transaction XDR. + * + * @param params - Payment parameters + * @returns Base64-encoded transaction XDR + */ + async buildPaymentXdr(params: PaymentParams): Promise { + const { source, destination, amount, assetCode, issuer, memo, network } = params; + + if (!isValidPaymentAccountAddress(source)) { + throw new Error('source must be a valid Stellar public or muxed public key'); + } + + if (!isValidPaymentAccountAddress(destination)) { + throw new Error('destination must be a valid Stellar public or muxed public key'); + } + + const networkPassphrase = + network === 'public' + ? Networks.PUBLIC + : network === 'futurenet' + ? Networks.FUTURENET + : Networks.TESTNET; + + if (assetCode !== 'XLM' && (!issuer || !ValidationUtils.isValidStellarAddress(issuer))) { + throw new Error(`A valid issuer is required for non-native asset payments: ${assetCode}`); + } + + const asset = assetCode === 'XLM' ? Asset.native() : new Asset(assetCode, issuer); + + // We use a dummy sequence number because the actual submission will be handled later + // or by a signer that manages sequence numbers. + const sourceAccount = StrKey.isValidMed25519PublicKey(source) + ? MuxedAccount.fromAddress(source, '0') + : new Account(source, '0'); + + const builder = new TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase, + }) + .addOperation( + Operation.payment({ + destination, + asset, + amount, + }), + ) + .setTimeout(0); // Added .setTimeout(0) + + if (memo) { + let stellarMemo: StellarMemo; + switch (memo.type) { + case 'text': + stellarMemo = StellarMemo.text(memo.value); + break; + case 'id': + stellarMemo = StellarMemo.id(memo.value); + break; + case 'hash': + stellarMemo = StellarMemo.hash(memo.value); + break; + case 'return': + stellarMemo = StellarMemo.return(memo.value); + break; + default: + throw new Error(`Unsupported memo type: ${memo.type}`); + } + builder.addMemo(stellarMemo); + } + + return builder.build().toXDR(); + }, + + /** + * Validates a Stellar account ID (starting with 'G'). + * + * @param accountId - The public key to validate + * @returns true if valid, false otherwise + */ + validateAccountId(accountId: string): boolean { + return ValidationUtils.isValidStellarAddress(accountId); + }, +}; + +function isValidPaymentAccountAddress(address: string): boolean { + return StrKey.isValidEd25519PublicKey(address) || StrKey.isValidMed25519PublicKey(address); +} diff --git a/src/utils/validation.ts b/src/utils/validation.ts new file mode 100644 index 0000000..ddcf2f0 --- /dev/null +++ b/src/utils/validation.ts @@ -0,0 +1,378 @@ +import type { AnchorKitConfig, NetworkConfig, SecurityConfig } from '@/types/config.ts'; +import DOMPurify from 'isomorphic-dompurify'; + +/** + * ValidationUtils helper object + * Provides standard validation for common fields used in SEPs. + */ +export const ValidationUtils = { + /** + * Validates if the given string is a valid email address. + * Uses a standard regex pattern for common email verification. + * + * @param email The email address to validate. + * @returns true if valid, false otherwise. + */ + isValidEmail(email: string): boolean { + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + return emailRegex.test(email); + }, + + /** + * Validates if the given string is a valid E.164 phone number. + * Example: +1234567890 + * + * @param phone The phone number to validate. + * @returns true if valid, false otherwise. + */ + isValidPhoneNumber(phone: string): boolean { + const phoneRegex = /^\+[1-9]\d{1,14}$/; + return phoneRegex.test(phone); + }, + + /** + * Validates if the given string is a valid URL. + * + * @param url The URL string to validate. + * @returns true if valid, false otherwise. + */ + isValidUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } + }, + + /** + * Sanitizes input string by removing HTML tags and scripts. + * Uses DOMPurify for robust XSS prevention. + * + * @param input The raw input string. + * @returns Sanitized string. + */ + sanitizeInput(input: string): string { + if (!input) return ''; + return DOMPurify.sanitize(input, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] }).trim(); + }, + + /** + * Validates if a string is a valid decimal number. + * + * @param value The string to validate. + * @returns true if valid, false otherwise. + */ + isDecimal(value: string): boolean { + if (!value) return false; + return /^-?\d+(\.\d+)?$/.test(value); + }, + + /** + * Validates if a string is a valid Stellar public key (starting with 'G'). + * + * @param address The address to validate. + * @returns true if valid, false otherwise. + */ + isValidStellarAddress(address: string): boolean { + if (!address || typeof address !== 'string') return false; + // Basic format check to avoid loading full SDK if obviously wrong + if (!/^G[A-Z2-7]{55}$/.test(address)) return false; + return true; + }, + + /** + * Validates database connection strings or file paths loosely. + * + * @param urlString The database URL. + * @returns true if valid, false otherwise. + */ + isValidDatabaseUrl(urlString: string): boolean { + return DatabaseUrlSchema.isValid(urlString); + }, +}; + +/** + * AssetSchema + * Validation schema for individual Asset entries. + */ +export const AssetSchema = { + /** + * Validates if the given object is a valid Asset entry. + * + * @param asset The asset object to validate. + * @returns true if valid, false otherwise. + */ + isValid(asset: unknown): boolean { + if (!asset || typeof asset !== 'object') return false; + const a = asset as Record; + + // Required fields: code, issuer + if (typeof a.code !== 'string' || a.code.length === 0) return false; + if (typeof a.issuer !== 'string' || !ValidationUtils.isValidStellarAddress(a.issuer)) { + return false; + } + + // Optional fields if provided must have correct type + if (a.name !== undefined && typeof a.name !== 'string') return false; + if (a.deposits_enabled !== undefined && typeof a.deposits_enabled !== 'boolean') return false; + if (a.withdrawals_enabled !== undefined && typeof a.withdrawals_enabled !== 'boolean') + return false; + if (a.min_amount !== undefined && typeof a.min_amount !== 'number') return false; + if (a.max_amount !== undefined && typeof a.max_amount !== 'number') return false; + + return true; + }, +}; + +/** + * DatabaseUrlSchema + * Restricts database URLs to supported schemes (postgres, sqlite). + */ +export const DatabaseUrlSchema = { + /** + * Validates if the given string is a supported database URL. + * Acceptable schemes: postgresql:, postgres:, sqlite:, file: + * + * @param urlString The database URL string. + * @returns true if supported, false otherwise. + */ + isValid(urlString: string): boolean { + if (!urlString || typeof urlString !== 'string') return false; + + const validSchemes = ['postgresql:', 'postgres:', 'sqlite:', 'file:']; + return validSchemes.some((scheme) => urlString.startsWith(scheme)); + }, +}; + +/** + * NetworkConfigSchema - Public validation helper for nested network configuration. + */ +export const NetworkConfigSchema = { + /** + * Validates a NetworkConfig object. + * Throws an error if validation fails. + * + * @param config The NetworkConfig object to validate. + */ + validate(config: NetworkConfig): void { + if (!config) throw new Error('Missing required field: network'); + const validNetworks = ['public', 'testnet', 'futurenet']; + if (!validNetworks.includes(config.network)) { + throw new Error( + `Invalid network: ${config.network}. Must be one of: ${validNetworks.join(', ')}`, + ); + } + if (config.horizonUrl && !ValidationUtils.isValidUrl(config.horizonUrl)) { + throw new Error('Invalid URL format for network.horizonUrl'); + } + }, +}; + +/** + * SecurityConfigSchema - Public validation helper for security configuration. + */ +export const SecurityConfigSchema = { + /** + * Validates a SecurityConfig object. + * Throws an error if validation fails. + * + * @param config The SecurityConfig object to validate. + */ + validate(config: SecurityConfig): void { + if (!config) throw new Error('Missing required field: security'); + if (!config.sep10SigningKey) + throw new Error('Missing required secret: security.sep10SigningKey'); + if (!config.interactiveJwtSecret) + throw new Error('Missing required secret: security.interactiveJwtSecret'); + if (!config.distributionAccountSecret) + throw new Error('Missing required secret: security.distributionAccountSecret'); + if ( + config.authTokenLifetimeSeconds !== undefined && + (typeof config.authTokenLifetimeSeconds !== 'number' || + !Number.isFinite(config.authTokenLifetimeSeconds) || + config.authTokenLifetimeSeconds <= 0) + ) { + throw new Error('security.authTokenLifetimeSeconds must be > 0'); + } + }, +}; + +/** + * AnchorKitConfigSchema - Public validation helper for the top-level configuration object. + */ +export const AnchorKitConfigSchema = { + /** + * Validates the complete AnchorKitConfig object. + * Throws an error if validation fails. + * + * @param config The AnchorKitConfig object to validate. + */ + validate(config: AnchorKitConfig): void { + if (!config) throw new Error('Configuration object is missing'); + + const { network, server, security, assets, framework, metadata } = config; + + // Validate Sections + if (!network) throw new Error('Missing required top-level field: network'); + if (!server) throw new Error('Missing required top-level field: server'); + if (!security) throw new Error('Missing required top-level field: security'); + if (!assets) throw new Error('Missing required top-level field: assets'); + if (!framework) throw new Error('Missing required top-level field: framework'); + + // Network Section + NetworkConfigSchema.validate(network); + + // Security Section + SecurityConfigSchema.validate(security); + + // Assets Section + if (!assets.assets || !Array.isArray(assets.assets) || assets.assets.length === 0) { + throw new Error('At least one asset must be configured in assets.assets'); + } + + // Framework Database config + if (!framework.database || !framework.database.provider || !framework.database.url) { + throw new Error('Missing required database configuration in framework.database'); + } + + if (framework.database.provider === 'mysql') { + throw new Error( + 'MySQL is not currently supported in this MVP. Please use "postgres" or "sqlite".', + ); + } + + if (!ValidationUtils.isValidDatabaseUrl(framework.database.url)) { + throw new Error('Invalid database URL format'); + } + + // Framework Numbers + if (framework.queue?.concurrency !== undefined && framework.queue.concurrency < 1) { + throw new Error('framework.queue.concurrency must be >= 1'); + } + if ( + framework.watchers?.pollIntervalMs !== undefined && + framework.watchers.pollIntervalMs < 10 + ) { + throw new Error('framework.watchers.pollIntervalMs must be >= 10'); + } + if (framework.http?.maxBodyBytes !== undefined && framework.http.maxBodyBytes < 1024) { + throw new Error('framework.http.maxBodyBytes must be >= 1024'); + } + + if (framework.rateLimit) { + const rateValues = [ + framework.rateLimit.windowMs, + framework.rateLimit.authChallengeMax, + framework.rateLimit.authTokenMax, + framework.rateLimit.webhookMax, + framework.rateLimit.depositMax, + ]; + if (rateValues.some((value) => value !== undefined && value <= 0)) { + throw new Error('framework.rateLimit values must be > 0'); + } + } + + // Other URLs + if (server.interactiveDomain && !ValidationUtils.isValidUrl(server.interactiveDomain)) { + throw new Error('Invalid URL format for server.interactiveDomain'); + } + if (metadata?.tomlUrl && !ValidationUtils.isValidUrl(metadata.tomlUrl)) { + throw new Error('Invalid URL format for metadata.tomlUrl'); + } + }, +}; + +// --------------------------------------------------------------------------- +// ServerConfigSchema +// --------------------------------------------------------------------------- + +import type { ServerConfig } from '../types/config.ts'; + +export interface SchemaField { + type: string; + required: boolean; + description: string; + validate: (value: unknown) => boolean; +} + +/** + * ServerConfigSchema + * Runtime schema for validating partial ServerConfig objects. + * + * @example + * import { ServerConfigSchema } from 'anchor-kit'; + * ServerConfigSchema.port.validate(3000); // true + */ +export const ServerConfigSchema: Record, SchemaField> = { + host: { + type: 'string', + required: false, + description: 'Server host address. Defaults to 0.0.0.0', + validate: (value) => typeof value === 'string' && value.length > 0, + }, + port: { + type: 'number', + required: false, + description: 'Server port number. Defaults to 3000.', + validate: (value) => + typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 65535, + }, + debug: { + type: 'boolean', + required: false, + description: 'Enable debug mode for verbose logging. Defaults to false.', + validate: (value) => typeof value === 'boolean', + }, + interactiveDomain: { + type: 'string', + required: false, + description: 'Interactive web portal domain/URL for SEP-24 flows.', + validate: (value) => { + if (typeof value !== 'string' || value.length === 0) return false; + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } + }, + }, + corsOrigins: { + type: 'string[]', + required: false, + description: 'Allowed origins for CORS.', + validate: (value) => + Array.isArray(value) && + value.every((origin) => typeof origin === 'string' && origin.length > 0), + }, + requestTimeout: { + type: 'number', + required: false, + description: 'Request timeout in milliseconds. Defaults to 30000.', + validate: (value) => typeof value === 'number' && Number.isFinite(value) && value > 0, + }, +}; + +/** + * validateServerConfig + * Validates a partial ServerConfig object. Returns array of error strings. + * + * @example + * validateServerConfig({ port: -1 }); // ['port: invalid value'] + */ +export function validateServerConfig(config: Partial): string[] { + const errors: string[] = []; + for (const [key, field] of Object.entries(ServerConfigSchema) as [ + keyof ServerConfig, + SchemaField, + ][]) { + const value = config[key]; + if (value === undefined || value === null) { + if (field.required) errors.push(`${key}: is required`); + continue; + } + if (!field.validate(value)) errors.push(`${key}: invalid value`); + } + return errors; +} diff --git a/tests/core/config-validation-improvements.test.ts b/tests/core/config-validation-improvements.test.ts new file mode 100644 index 0000000..3697bd2 --- /dev/null +++ b/tests/core/config-validation-improvements.test.ts @@ -0,0 +1,136 @@ +import { AnchorConfig } from '../../src/core/config'; +import { ConfigError } from '../../src/core/errors'; +import type { AnchorKitConfig } from '../../src/types/config'; +import { describe, expect, it } from 'vitest'; + +describe('Config Validation Improvements (#124, #125)', () => { + const validBaseConfig: AnchorKitConfig = { + network: { network: 'testnet' }, + server: { port: 3000 }, + security: { + sep10SigningKey: 'secret-key-10', + interactiveJwtSecret: 'jwt-secret', + distributionAccountSecret: 'dist-secret', + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }, + ], + }, + framework: { + database: { + provider: 'postgres', + url: 'postgresql://localhost:5432/anchor', + }, + }, + }; + + it('should reject MySQL provider during validation (#124)', () => { + const mysqlConfig: AnchorKitConfig = { + ...validBaseConfig, + framework: { + ...validBaseConfig.framework, + database: { + provider: 'mysql', // NOT SUPPORTED + url: 'mysql://user:pass@localhost:3306/db', + }, + }, + }; + const config = new AnchorConfig(mysqlConfig); + expect(() => config.validate()).toThrow(ConfigError); + expect(() => config.validate()).toThrow(/MySQL is not currently supported/); + }); + + it('should accept sqlite provider during validation', () => { + const sqliteConfig: AnchorKitConfig = { + ...validBaseConfig, + framework: { + ...validBaseConfig.framework, + database: { + provider: 'sqlite', + url: 'file:./dev.db', + }, + }, + }; + const config = new AnchorConfig(sqliteConfig); + expect(() => config.validate()).not.toThrow(); + }); + + it('should reject non-database schemes in database URL (#125)', () => { + const ftpConfig: AnchorKitConfig = { + ...validBaseConfig, + framework: { + ...validBaseConfig.framework, + database: { + provider: 'postgres', + url: 'ftp://ftp.example.com/db', // NOT a DATABASE URL + }, + }, + }; + const config = new AnchorConfig(ftpConfig); + expect(() => config.validate()).toThrow(ConfigError); + expect(() => config.validate()).toThrow(/Invalid database URL format/); + }); + + it('should accept valid postgres URLs', () => { + const postgresConfigs = [ + 'postgresql://localhost:5432/mydb', + 'postgres://user:pass@host.com/db', + ]; + + postgresConfigs.forEach((url) => { + const config = new AnchorConfig({ + ...validBaseConfig, + framework: { + ...validBaseConfig.framework, + database: { + provider: 'postgres', + url, + }, + }, + }); + expect(() => config.validate()).not.toThrow(); + }); + }); + + it('should accept valid sqlite URLs', () => { + const sqliteConfigs = ['sqlite:./local.db', 'file:./data.db']; + + sqliteConfigs.forEach((url) => { + const config = new AnchorConfig({ + ...validBaseConfig, + framework: { + ...validBaseConfig.framework, + database: { + provider: 'sqlite', + url, + }, + }, + }); + expect(() => config.validate()).not.toThrow(); + }); + }); + + it('should reject non-HTTP(S) schemes for server.interactiveDomain', () => { + const badConfig: AnchorKitConfig = { + ...validBaseConfig, + server: { interactiveDomain: 'javascript:alert(1)' as unknown as string }, + }; + const config = new AnchorConfig(badConfig); + expect(() => config.validate()).toThrow(ConfigError); + expect(() => config.validate()).toThrow(/Invalid URL format for server.interactiveDomain/); + }); + + it('should reject non-HTTP(S) schemes for metadata.tomlUrl', () => { + const badConfig: AnchorKitConfig = { + ...validBaseConfig, + metadata: { tomlUrl: 'file:///etc/passwd' } as unknown as AnchorKitConfig['metadata'], + }; + const config = new AnchorConfig(badConfig); + expect(() => config.validate()).toThrow(ConfigError); + expect(() => config.validate()).toThrow(/Invalid URL format for metadata.tomlUrl/); + }); +}); diff --git a/tests/core/config.test.ts b/tests/core/config.test.ts index 0b9f1f6..30dfd0f 100644 --- a/tests/core/config.test.ts +++ b/tests/core/config.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect } from 'vitest'; -import { AnchorConfig } from '../../src/core/config.ts'; -import { ConfigurationError } from '../../src/core/errors.ts'; -import type { AnchorKitConfig } from '../../src/types/config.ts'; +import { AnchorConfig } from '@/core/config.ts'; +import { ConfigError } from '@/core/errors.ts'; +import type { AnchorKitConfig } from '@/types/config.ts'; +import { Networks } from '@stellar/stellar-sdk'; +import { describe, expect, it } from 'vitest'; describe('AnchorConfig', () => { const validBaseConfig: AnchorKitConfig = { @@ -139,7 +140,7 @@ describe('AnchorConfig', () => { network: { network: 'futurenet' }, }; const config = new AnchorConfig(configFuturenet); - expect(config.isNetworkPassphrase('Test SDF Future Network ; Fall 2022')).toBe(true); + expect(config.isNetworkPassphrase(Networks.FUTURENET)).toBe(true); expect(config.isNetworkPassphrase('Test SDF Network ; September 2015')).toBe(false); }); }); @@ -150,37 +151,37 @@ describe('AnchorConfig', () => { expect(() => config.validate()).not.toThrow(); }); - it('should throw ConfigurationError if top-level network is missing', () => { + it('should throw ConfigError if top-level network is missing', () => { // @ts-expect-error this is for test cases const invalidConfig: AnchorKitConfig = { ...validBaseConfig, network: undefined }; const config = new AnchorConfig(invalidConfig); - expect(() => config.validate()).toThrow(ConfigurationError); + expect(() => config.validate()).toThrow(ConfigError); expect(() => config.validate()).toThrow(/network/); }); - it('should throw ConfigurationError if required secrets are missing', () => { + it('should throw ConfigError if required secrets are missing', () => { const invalidConfig: AnchorKitConfig = { ...validBaseConfig, security: { ...validBaseConfig.security, sep10SigningKey: '' }, }; const config = new AnchorConfig(invalidConfig); - expect(() => config.validate()).toThrow(ConfigurationError); + expect(() => config.validate()).toThrow(ConfigError); expect(() => config.validate()).toThrow(/sep10SigningKey/); }); - it('should throw ConfigurationError for missing assets list', () => { + it('should throw ConfigError for missing assets list', () => { const invalidConfig: AnchorKitConfig = { ...validBaseConfig, assets: { assets: [] }, }; const config = new AnchorConfig(invalidConfig); - expect(() => config.validate()).toThrow(ConfigurationError); + expect(() => config.validate()).toThrow(ConfigError); expect(() => config.validate()).toThrow(/asset/); }); - it('should throw ConfigurationError for invalid network string', () => { + it('should throw ConfigError for invalid network string', () => { const invalidConfig: AnchorKitConfig = { ...validBaseConfig, // @ts-expect-error this is for test cases @@ -188,7 +189,7 @@ describe('AnchorConfig', () => { }; const config = new AnchorConfig(invalidConfig); - expect(() => config.validate()).toThrow(ConfigurationError); + expect(() => config.validate()).toThrow(ConfigError); expect(() => config.validate()).toThrow(/Invalid network: invalidnet/); }); @@ -200,7 +201,7 @@ describe('AnchorConfig', () => { }; const config = new AnchorConfig(invalidConfig); - expect(() => config.validate()).toThrow(ConfigurationError); + expect(() => config.validate()).toThrow(ConfigError); expect(() => config.validate()).toThrow(/database/); }); @@ -216,7 +217,7 @@ describe('AnchorConfig', () => { }; const config = new AnchorConfig(invalidConfig); - expect(() => config.validate()).toThrow(ConfigurationError); + expect(() => config.validate()).toThrow(ConfigError); expect(() => config.validate()).toThrow(/Invalid database URL format/); }); @@ -227,7 +228,7 @@ describe('AnchorConfig', () => { }; const config = new AnchorConfig(invalidConfig); - expect(() => config.validate()).toThrow(ConfigurationError); + expect(() => config.validate()).toThrow(ConfigError); expect(() => config.validate()).toThrow(/Invalid URL format for server\.interactiveDomain/); }); @@ -244,5 +245,49 @@ describe('AnchorConfig', () => { const config = new AnchorConfig(sqliteConfig); expect(() => config.validate()).not.toThrow(); }); + + it('should accept valid auth token lifetime', () => { + const configWithTtl: AnchorKitConfig = { + ...validBaseConfig, + security: { + ...validBaseConfig.security, + authTokenLifetimeSeconds: 7200, + }, + }; + const config = new AnchorConfig(configWithTtl); + expect(() => config.validate()).not.toThrow(); + expect(config.get('security').authTokenLifetimeSeconds).toBe(7200); + }); + + it('should use default TTL when not specified', () => { + const config = new AnchorConfig(validBaseConfig); + expect(config.get('security').authTokenLifetimeSeconds).toBeUndefined(); + }); + + it('should reject invalid auth token lifetime (zero)', () => { + const invalidConfig: AnchorKitConfig = { + ...validBaseConfig, + security: { + ...validBaseConfig.security, + authTokenLifetimeSeconds: 0, + }, + }; + const config = new AnchorConfig(invalidConfig); + expect(() => config.validate()).toThrow(ConfigError); + expect(() => config.validate()).toThrow(/authTokenLifetimeSeconds must be > 0/); + }); + + it('should reject invalid auth token lifetime (negative)', () => { + const invalidConfig: AnchorKitConfig = { + ...validBaseConfig, + security: { + ...validBaseConfig.security, + authTokenLifetimeSeconds: -100, + }, + }; + const config = new AnchorConfig(invalidConfig); + expect(() => config.validate()).toThrow(ConfigError); + expect(() => config.validate()).toThrow(/authTokenLifetimeSeconds must be > 0/); + }); }); }); diff --git a/tests/core/errors.test.ts b/tests/core/errors.test.ts new file mode 100644 index 0000000..f2ba885 --- /dev/null +++ b/tests/core/errors.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { + TransactionStateError, + RailError, + ConfigError, + ValidationError, + SepProtocolError, + NetworkError, + CryptoError, +} from '@/core/errors.ts'; +import { AnchorKitError } from '../../src/core/errors'; + +// Test subclass since AnchorKitError is abstract +class TestError extends AnchorKitError { + public readonly statusCode = 400; + public readonly errorCode = 'TEST_ERROR'; + + constructor(message: string, context?: Record) { + super(message, context); + } +} + +describe('AnchorKitError', () => { + const originalEnv = process.env.NODE_ENV; + + afterEach(() => { + process.env.NODE_ENV = originalEnv; + }); + + it('should create an error with basic properties', () => { + const error = new TestError('This is a test'); + + expect(error.message).toBe('This is a test'); + expect(error.statusCode).toBe(400); + expect(error.errorCode).toBe('TEST_ERROR'); + expect(error.context).toBeUndefined(); + expect(error.name).toBe('TestError'); + }); + + it('should include context when provided', () => { + const context = { details: 'More info' }; + const error = new TestError('Error with context', context); + + expect(error.context).toEqual(context); + }); + + describe('toJSON()', () => { + it('should format output correctly in non-dev environment', () => { + process.env.NODE_ENV = 'production'; + const error = new TestError('Production error', { secret: 'hidden' }); + + const json = error.toJSON(); + + expect(json).toEqual({ + error: 'TEST_ERROR', + message: 'Production error', + }); + // Context should not be exposed in production + expect(json).not.toHaveProperty('context'); + }); + + it('should include context in dev environment', () => { + process.env.NODE_ENV = 'development'; + const context = { debugInfo: 'stuff' }; + const error = new TestError('Dev error', context); + + const json = error.toJSON(); + + expect(json).toEqual({ + error: 'TEST_ERROR', + message: 'Dev error', + context: { debugInfo: 'stuff' }, + }); + }); + }); +}); + +describe('ConfigError', () => { + it('maps statusCode and errorCode', () => { + const err = new ConfigError('missing secret'); + expect(err.statusCode).toBe(500); + expect(err.errorCode).toBe('INVALID_CONFIG'); + }); +}); + +describe('ValidationError', () => { + it('maps statusCode and errorCode', () => { + const err = new ValidationError('invalid parameter'); + expect(err.statusCode).toBe(400); + expect(err.errorCode).toBe('INVALID_REQUEST'); + }); +}); + +describe('SepProtocolError', () => { + it('maps statusCode and errorCode correctly', () => { + const err = new SepProtocolError('customer not found', 'customer_info_needed'); + expect(err.statusCode).toBe(400); + expect(err.errorCode).toBe('customer_info_needed'); + }); + + it('preserves optional sepErrorType and context', () => { + const err = new SepProtocolError('bad request', 'bad_request', 'invalid_field', { + field: 'amount', + }); + + expect(err.sepErrorType).toBe('invalid_field'); + expect(err.context).toEqual({ + errorCode: 'bad_request', + sepErrorType: 'invalid_field', + field: 'amount', + }); + }); +}); + +describe('TransactionStateError', () => { + it('maps statusCode and errorCode and exposes transition metadata', () => { + const err = new TransactionStateError('invalid transition', 'pending', 'completed', { + reason: 'test', + }); + + expect(err).toBeInstanceOf(TransactionStateError); + expect(err.statusCode).toBe(400); + expect(err.errorCode).toBe('INVALID_STATE_TRANSITION'); + expect(err.currentStatus).toBe('pending'); + expect(err.attemptedStatus).toBe('completed'); + expect(err.context).toEqual( + expect.objectContaining({ + currentStatus: 'pending', + attemptedStatus: 'completed', + reason: 'test', + }), + ); + }); +}); + +describe('RailError', () => { + it('maps statusCode and errorCode and exposes rail metadata', () => { + const err = new RailError('rail failure', 'ACH', { reason: 'network down' }); + + expect(err).toBeInstanceOf(RailError); + expect(err.statusCode).toBe(500); + expect(err.errorCode).toBe('RAIL_ERROR'); + expect(err.railName).toBe('ACH'); + expect(err.context).toEqual( + expect.objectContaining({ + railName: 'ACH', + reason: 'network down', + }), + ); + }); + + it('handles optional railName', () => { + const err = new RailError('generic rail failure'); + + expect(err).toBeInstanceOf(RailError); + expect(err.statusCode).toBe(500); + expect(err.errorCode).toBe('RAIL_ERROR'); + expect(err.railName).toBeUndefined(); + }); +}); + +describe('NetworkError', () => { + it('maps statusCode and errorCode correctly', () => { + const err = new NetworkError('service unreachable'); + + expect(err).toBeInstanceOf(NetworkError); + expect(err.statusCode).toBe(502); + expect(err.errorCode).toBe('NETWORK_ERROR'); + }); + + it('supports optional httpStatusFromUpstream', () => { + const err = new NetworkError('upstream service error', 503); + + expect(err).toBeInstanceOf(NetworkError); + expect(err.statusCode).toBe(502); + expect(err.errorCode).toBe('NETWORK_ERROR'); + expect(err.httpStatusFromUpstream).toBe(503); + expect(err.context).toEqual({ + httpStatusFromUpstream: 503, + }); + }); + + it('preserves additional context alongside upstream status', () => { + const err = new NetworkError('timeout connecting to oracle', 504, { service: 'price-oracle' }); + + expect(err).toBeInstanceOf(NetworkError); + expect(err.statusCode).toBe(502); + expect(err.errorCode).toBe('NETWORK_ERROR'); + expect(err.httpStatusFromUpstream).toBe(504); + expect(err.context).toEqual({ + service: 'price-oracle', + httpStatusFromUpstream: 504, + }); + }); + + it('handles missing optional httpStatusFromUpstream', () => { + const err = new NetworkError('connection refused', undefined, { retry: true }); + + expect(err).toBeInstanceOf(NetworkError); + expect(err.statusCode).toBe(502); + expect(err.errorCode).toBe('NETWORK_ERROR'); + expect(err.httpStatusFromUpstream).toBeUndefined(); + expect(err.context).toEqual({ + retry: true, + httpStatusFromUpstream: undefined, + }); + }); +}); + +describe('CryptoError', () => { + it('maps statusCode and errorCode', () => { + const err = new CryptoError('encryption failed'); + expect(err.statusCode).toBe(500); + expect(err.errorCode).toBe('CRYPTO_ERROR'); + }); + + it('correctly inherits from AnchorKitError', () => { + const err = new CryptoError('bad key'); + expect(err).toBeInstanceOf(CryptoError); + expect(err).toBeInstanceOf(AnchorKitError); + }); +}); diff --git a/tests/example-express-app.test.ts b/tests/example-express-app.test.ts new file mode 100644 index 0000000..0e089f9 --- /dev/null +++ b/tests/example-express-app.test.ts @@ -0,0 +1,260 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { Express } from 'express'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { unlinkSync } from 'node:fs'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { Keypair, Transaction } from '@stellar/stellar-sdk'; +import { createExampleApp } from '../example/express-app.ts'; + +interface ExampleAppRuntime { + app: Express; + anchor: { + config: { + get: (key: 'framework') => { + watchers?: { + enabled?: boolean; + }; + }; + }; + }; + shutdown: () => Promise; +} + +interface InvokeOptions { + method?: string; + path: string; + headers?: Record; + body?: Record; +} + +interface InvokeResponse { + status: number; + body: Record; +} + +const DEFAULT_CHALLENGE_EXPIRATION_SECONDS = 300; + +interface ExampleAppHarness { + runtime: ExampleAppRuntime; + cleanup: () => Promise; +} + +function getChallengeLifetimeSeconds(challengeTx: Transaction): number { + if (!challengeTx.timeBounds) { + throw new Error('Expected SEP-10 challenge transaction to include time bounds'); + } + + return Number(challengeTx.timeBounds.maxTime) - Number(challengeTx.timeBounds.minTime); +} + +function setOptionalEnvVar(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + return; + } + + process.env[key] = value; +} + +function removeFileIfPresent(path: string): void { + try { + unlinkSync(path); + } catch { + // ignore cleanup errors + } +} + +async function invokeExpress(app: Express, options: InvokeOptions): Promise { + const serializedBody = options.body ? JSON.stringify(options.body) : ''; + + const req = Readable.from(serializedBody ? [serializedBody] : []) as IncomingMessage & { + method: string; + url: string; + headers: Record; + }; + + req.method = options.method ?? 'GET'; + req.url = options.path; + req.headers = Object.fromEntries( + Object.entries(options.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]), + ); + + const responseHeaders: Record = {}; + + return new Promise((resolve) => { + let statusCode = 200; + + const res = { + get statusCode(): number { + return statusCode; + }, + set statusCode(value: number) { + statusCode = value; + }, + setHeader(name: string, value: string): void { + responseHeaders[name.toLowerCase()] = value; + }, + end(payload?: string): void { + const contentType = responseHeaders['content-type'] ?? ''; + const bodyText = typeof payload === 'string' ? payload : ''; + const body = + contentType.includes('application/json') && bodyText + ? (JSON.parse(bodyText) as Record) + : {}; + + resolve({ + status: statusCode, + body, + }); + }, + } as unknown as ServerResponse; + + app(req, res); + }); +} + +async function createExampleAppHarness( + options: { + challengeExpirationSeconds?: string; + watchersEnabled?: string; + } = {}, +): Promise { + const sep10ServerKeypair = Keypair.random(); + const dbPath = join(tmpdir(), `anchor-kit-example-test-${Date.now()}-${Math.random()}.sqlite`); + const originalDatabaseUrl = process.env.DATABASE_URL; + const originalSep10SigningKey = process.env.SEP10_SIGNING_KEY; + const originalChallengeExpirationSeconds = process.env.CHALLENGE_EXPIRATION_SECONDS; + const originalWatchersEnabled = process.env.WATCHERS_ENABLED; + + setOptionalEnvVar('DATABASE_URL', `file:${dbPath}`); + setOptionalEnvVar('SEP10_SIGNING_KEY', sep10ServerKeypair.secret()); + setOptionalEnvVar('CHALLENGE_EXPIRATION_SECONDS', options.challengeExpirationSeconds); + setOptionalEnvVar('WATCHERS_ENABLED', options.watchersEnabled); + + const runtime = await createExampleApp(); + + return { + runtime, + cleanup: async () => { + await runtime.shutdown(); + + setOptionalEnvVar('DATABASE_URL', originalDatabaseUrl); + setOptionalEnvVar('SEP10_SIGNING_KEY', originalSep10SigningKey); + setOptionalEnvVar('CHALLENGE_EXPIRATION_SECONDS', originalChallengeExpirationSeconds); + setOptionalEnvVar('WATCHERS_ENABLED', originalWatchersEnabled); + removeFileIfPresent(dbPath); + }, + }; +} + +describe('example/express-app', () => { + const clientKeypair = Keypair.random(); + let harness: ExampleAppHarness; + + beforeAll(async () => { + harness = await createExampleAppHarness(); + }); + + afterAll(async () => { + await harness.cleanup(); + }); + + it('mounts /anchor and serves /health', async () => { + const response = await invokeExpress(harness.runtime.app, { path: '/anchor/health' }); + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + + it('runs challenge -> token flow', async () => { + const account = clientKeypair.publicKey(); + + const challengeResponse = await invokeExpress(harness.runtime.app, { + path: `/anchor/auth/challenge?account=${account}`, + }); + expect(challengeResponse.status).toBe(200); + const networkPassphrase = String(challengeResponse.body.network_passphrase ?? ''); + const challengeXdr = String(challengeResponse.body.challenge ?? ''); + const challengeTx = new Transaction(challengeXdr, networkPassphrase); + challengeTx.sign(clientKeypair); + const signedChallengeXdr = challengeTx.toXDR(); + + const tokenResponse = await invokeExpress(harness.runtime.app, { + method: 'POST', + path: '/anchor/auth/token', + headers: { 'content-type': 'application/json' }, + body: { + account, + challenge: signedChallengeXdr, + }, + }); + + expect(tokenResponse.status).toBe(200); + expect(typeof tokenResponse.body.token).toBe('string'); + expect(String(tokenResponse.body.token).length).toBeGreaterThan(0); + }); + + it('uses the default challenge expiration when the env var is absent', async () => { + const account = clientKeypair.publicKey(); + + const challengeResponse = await invokeExpress(harness.runtime.app, { + path: `/anchor/auth/challenge?account=${account}`, + }); + + expect(challengeResponse.status).toBe(200); + const networkPassphrase = String(challengeResponse.body.network_passphrase ?? ''); + const challengeXdr = String(challengeResponse.body.challenge ?? ''); + const challengeTx = new Transaction(challengeXdr, networkPassphrase); + + expect(getChallengeLifetimeSeconds(challengeTx)).toBe(DEFAULT_CHALLENGE_EXPIRATION_SECONDS); + }); + + it('keeps watchers enabled when the env var is absent', () => { + expect(harness.runtime.anchor.config.get('framework').watchers?.enabled).toBe(true); + }); +}); + +describe('example/express-app CHALLENGE_EXPIRATION_SECONDS', () => { + let harness: ExampleAppHarness; + + beforeAll(async () => { + harness = await createExampleAppHarness({ challengeExpirationSeconds: '45' }); + }); + + afterAll(async () => { + await harness.cleanup(); + }); + + it('uses the configured challenge expiration from the environment', async () => { + const clientKeypair = Keypair.random(); + const account = clientKeypair.publicKey(); + + const challengeResponse = await invokeExpress(harness.runtime.app, { + path: `/anchor/auth/challenge?account=${account}`, + }); + + expect(challengeResponse.status).toBe(200); + const networkPassphrase = String(challengeResponse.body.network_passphrase ?? ''); + const challengeXdr = String(challengeResponse.body.challenge ?? ''); + const challengeTx = new Transaction(challengeXdr, networkPassphrase); + + expect(getChallengeLifetimeSeconds(challengeTx)).toBe(45); + }); +}); + +describe('example/express-app WATCHERS_ENABLED', () => { + let harness: ExampleAppHarness; + + beforeAll(async () => { + harness = await createExampleAppHarness({ watchersEnabled: 'false' }); + }); + + afterAll(async () => { + await harness.cleanup(); + }); + + it('disables watchers when configured through the environment', () => { + expect(harness.runtime.anchor.config.get('framework').watchers?.enabled).toBe(false); + }); +}); diff --git a/tests/kyc.test.ts b/tests/kyc.test.ts index 6767fd6..67defd2 100644 --- a/tests/kyc.test.ts +++ b/tests/kyc.test.ts @@ -1,6 +1,13 @@ -import { describe, it, expectTypeOf } from 'vitest'; +import { describe, it } from 'vitest'; import type { KycData, KycStatus } from '../src/types'; +function expectTypeOf(_value?: T) { + return { + toEqualTypeOf(_?: U): void {}, + toMatchTypeOf(_?: U): void {}, + }; +} + describe('KycData Type Tests', () => { it('should export KycData from types barrel', () => { const sample: KycData = { diff --git a/tests/mvp-express.integration.test.ts b/tests/mvp-express.integration.test.ts new file mode 100644 index 0000000..53ba535 --- /dev/null +++ b/tests/mvp-express.integration.test.ts @@ -0,0 +1,1165 @@ +import { makeSqliteDbUrlForTests } from '@/core/factory.ts'; +import { createAnchor, type AnchorInstance } from '@/index.ts'; +import { Keypair, Transaction } from '@stellar/stellar-sdk'; +import { createHmac } from 'node:crypto'; +import { unlinkSync } from 'node:fs'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { Readable } from 'node:stream'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { version } from '../package.json'; + +interface TestResponse { + status: number; + headers: Record; + body: Record; +} + +interface TestRequestOptions { + method?: string; + path: string; + headers?: Record; + body?: Record; +} + +function createMountedInvoker(anchor: AnchorInstance) { + const middleware = anchor.getExpressRouter(); + + return async (options: TestRequestOptions): Promise => { + const serializedBody = options.body ? JSON.stringify(options.body) : ''; + + const req = Readable.from(serializedBody ? [serializedBody] : []) as IncomingMessage & { + method: string; + url: string; + headers: Record; + body?: Record; + }; + + req.method = options.method ?? 'GET'; + req.url = `/anchor${options.path}`; + req.headers = Object.fromEntries( + Object.entries(options.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]), + ); + + const responseHeaders: Record = {}; + + const response = await new Promise((resolve) => { + let statusCode = 200; + let headersSent = false; + const res = { + get headersSent(): boolean { + return headersSent; + }, + set headersSent(value: boolean) { + headersSent = value; + }, + get statusCode(): number { + return statusCode; + }, + set statusCode(value: number) { + statusCode = value; + }, + setHeader(name: string, value: string): void { + responseHeaders[name.toLowerCase()] = value; + }, + end(payload?: string): void { + const contentType = responseHeaders['content-type'] ?? ''; + const bodyText = typeof payload === 'string' ? payload : ''; + const body = + contentType.includes('application/json') && bodyText + ? (JSON.parse(bodyText) as Record) + : {}; + resolve({ + status: statusCode, + headers: responseHeaders, + body, + }); + }, + } as unknown as ServerResponse; + + const rawUrl = req.url; + if (!rawUrl.startsWith('/anchor')) { + res.statusCode = 404; + res.end(JSON.stringify({ error: 'not_found' })); + return; + } + + req.url = rawUrl.slice('/anchor'.length) || '/'; + middleware(req, res, () => { + res.statusCode = 404; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: 'not_found' })); + }); + }); + + return response; + }; +} + +describe('MVP Express-mounted integration', () => { + const sep10ServerKeypair = Keypair.random(); + const clientKeypair = Keypair.random(); + const dbUrl = makeSqliteDbUrlForTests(); + const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice('file:'.length) : dbUrl; + + let webhookCallbackCount = 0; + let anchor: AnchorInstance; + let invoke: (options: TestRequestOptions) => Promise; + let accessToken = ''; + let transactionId = ''; + let depositInteractiveUrl = ''; + + beforeAll(async () => { + anchor = createAnchor({ + network: { network: 'testnet' }, + server: { interactiveDomain: 'https://anchor.example.com' }, + security: { + sep10SigningKey: sep10ServerKeypair.secret(), + interactiveJwtSecret: 'jwt-test-secret', + distributionAccountSecret: 'distribution-test-secret', + webhookSecret: 'webhook-test-secret', + verifyWebhookSignatures: true, + challengeExpirationSeconds: 300, + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + deposits_enabled: true, + min_amount: 10, + max_amount: 100, + }, + ], + }, + framework: { + database: { + provider: 'sqlite', + url: dbUrl, + }, + rateLimit: { + windowMs: 60000, + authChallengeMax: 2, + authTokenMax: 5, + webhookMax: 20, + depositMax: 20, + }, + queue: { + backend: 'memory', + concurrency: 2, + }, + watchers: { + enabled: true, + pollIntervalMs: 50, + transactionTimeoutMs: 50, + }, + }, + webhooks: { + onEvent: async () => { + webhookCallbackCount += 1; + }, + }, + }); + + await anchor.init(); + await anchor.startBackgroundJobs(); + invoke = createMountedInvoker(anchor); + }); + + afterAll(async () => { + await anchor.stopBackgroundJobs(); + await anchor.shutdown(); + + try { + unlinkSync(dbPath); + } catch { + // ignore cleanup errors in CI + } + }); + + it('1) app mounts router and /health works', async () => { + const response = await invoke({ path: '/health' }); + expect(response.status).toBe(200); + expect(response.body.status).toBe('ok'); + }); + + it('2) /info returns configured assets and package version', async () => { + const response = await invoke({ path: '/info' }); + expect(response.status).toBe(200); + const assets = response.body.assets; + expect(Array.isArray(assets)).toBe(true); + expect((assets as Array>)[0]?.code).toBe('USDC'); + expect(response.body.version).toBe(version); + expect(response.body.version).not.toBe('mvp'); + expect(response.body.interactive_domain).toBe('https://anchor.example.com'); + }); + + it('2b) /info includes support_email when configured', async () => { + const customDbUrl = makeSqliteDbUrlForTests(); + const customAnchor = createAnchor({ + network: { network: 'testnet' }, + server: {}, + security: { + sep10SigningKey: sep10ServerKeypair.secret(), + interactiveJwtSecret: 'jwt-test-secret-email', + distributionAccountSecret: 'distribution-test-secret', + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }, + ], + }, + operational: { supportEmail: 'support@example.com' }, + framework: { + database: { provider: 'sqlite', url: customDbUrl }, + }, + }); + + await customAnchor.init(); + const customInvoke = createMountedInvoker(customAnchor); + const response = await customInvoke({ path: '/info' }); + expect(response.status).toBe(200); + expect(response.body.support_email).toBe('support@example.com'); + + await customAnchor.shutdown(); + const customDbPath = customDbUrl.startsWith('file:') + ? customDbUrl.slice('file:'.length) + : customDbUrl; + try { + unlinkSync(customDbPath); + } catch { + /* ignore */ + } + }); + + it('2c) /info omits support_email when not configured', async () => { + const response = await invoke({ path: '/info' }); + expect(response.status).toBe(200); + expect(response.body).not.toHaveProperty('support_email'); + }); + + it('2d) /info omits interactive_domain when not configured', async () => { + const customDbUrl = makeSqliteDbUrlForTests(); + const customAnchor = createAnchor({ + network: { network: 'testnet' }, + server: { port: 3001 /* different port for safety */ }, + security: { + sep10SigningKey: sep10ServerKeypair.secret(), + interactiveJwtSecret: 'jwt-test-secret-no-domain', + distributionAccountSecret: 'distribution-test-secret', + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }, + ], + }, + framework: { + database: { + provider: 'sqlite', + url: customDbUrl, + }, + }, + }); + + await customAnchor.init(); + const customInvoke = createMountedInvoker(customAnchor); + const response = await customInvoke({ path: '/info' }); + expect(response.status).toBe(200); + expect(response.body).not.toHaveProperty('interactive_domain'); + + await customAnchor.shutdown(); + const customDbPath = customDbUrl.startsWith('file:') + ? customDbUrl.slice('file:'.length) + : customDbUrl; + try { + unlinkSync(customDbPath); + } catch { + // ignore + } + }); + + it('2e) deposit interactive returns server_misconfigured when interactiveDomain not set', async () => { + const customDbUrl = makeSqliteDbUrlForTests(); + const customAnchor = createAnchor({ + network: { network: 'testnet' }, + server: { port: 3002 /* different port for safety */ }, + security: { + sep10SigningKey: sep10ServerKeypair.secret(), + interactiveJwtSecret: 'jwt-test-secret-no-domain-2', + distributionAccountSecret: 'distribution-test-secret', + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + deposits_enabled: true, + }, + ], + }, + framework: { + database: { provider: 'sqlite', url: customDbUrl }, + }, + }); + + await customAnchor.init(); + const customInvoke = createMountedInvoker(customAnchor); + + // Obtain auth token + const testKeypair = Keypair.random(); + const challengeResponse = await customInvoke({ + path: `/auth/challenge?account=${testKeypair.publicKey()}`, + }); + const challengeXdr = String(challengeResponse.body.challenge ?? ''); + const networkPassphrase = String(challengeResponse.body.network_passphrase ?? ''); + const challengeTx = new Transaction(challengeXdr, networkPassphrase); + challengeTx.sign(testKeypair); + const tokenResponse = await customInvoke({ + method: 'POST', + path: '/auth/token', + headers: { 'content-type': 'application/json' }, + body: { account: testKeypair.publicKey(), challenge: challengeTx.toXDR() }, + }); + const token = String(tokenResponse.body.token ?? ''); + + const response = await customInvoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: { asset_code: 'USDC', amount: '10' }, + }); + + expect(response.status).toBe(500); + expect(response.body.error).toBe('server_misconfigured'); + + await customAnchor.shutdown(); + const customDbPath2 = customDbUrl.startsWith('file:') ? customDbUrl.slice('file:'.length) : customDbUrl; + try { + unlinkSync(customDbPath2); + } catch { + // ignore cleanup + } + }); + + it('3) challenge -> token happy path', async () => { + const account = clientKeypair.publicKey(); + const challengeResponse = await invoke({ + path: `/auth/challenge?account=${account}`, + headers: { 'x-forwarded-for': '10.0.0.1' }, + }); + expect(challengeResponse.status).toBe(200); + expect(challengeResponse.headers['cache-control']).toBe('no-store'); + const challengeXdr = String(challengeResponse.body.challenge ?? ''); + expect(challengeXdr.length).toBeGreaterThan(0); + const networkPassphrase = String(challengeResponse.body.network_passphrase ?? ''); + const challengeTx = new Transaction(challengeXdr, networkPassphrase); + challengeTx.sign(clientKeypair); + const signedChallengeXdr = challengeTx.toXDR(); + + const tokenResponse = await invoke({ + method: 'POST', + path: '/auth/token', + headers: { 'content-type': 'application/json', 'x-forwarded-for': '10.0.0.1' }, + body: { account, challenge: signedChallengeXdr }, + }); + + expect(tokenResponse.status).toBe(200); + accessToken = String(tokenResponse.body.token ?? ''); + expect(accessToken.length).toBeGreaterThan(0); + expect(tokenResponse.body.token_type).toBe('Bearer'); + expect(tokenResponse.headers['cache-control']).toBe('no-store'); + // Verify default TTL is used when not configured + expect(tokenResponse.body.expires_in).toBe(3600); + }); + + it('3b) auth token with custom TTL returns correct expires_in', async () => { + // Create a new anchor instance with custom TTL using a separate database + const customDbUrl = makeSqliteDbUrlForTests(); + const customAnchor = createAnchor({ + network: { network: 'testnet' }, + server: { interactiveDomain: 'https://anchor.example.com' }, + security: { + sep10SigningKey: sep10ServerKeypair.secret(), + interactiveJwtSecret: 'jwt-test-secret-custom', + distributionAccountSecret: 'distribution-test-secret', + webhookSecret: 'webhook-test-secret', + verifyWebhookSignatures: true, + challengeExpirationSeconds: 300, + authTokenLifetimeSeconds: 7200, // 2 hours + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + deposits_enabled: true, + }, + ], + }, + framework: { + database: { + provider: 'sqlite', + url: customDbUrl, + }, + }, + }); + + await customAnchor.init(); + const customInvoke = createMountedInvoker(customAnchor); + const testAccount = clientKeypair.publicKey(); + + // Get auth challenge + const challengeResponse = await customInvoke({ + path: `/auth/challenge?account=${testAccount}`, + headers: { 'x-forwarded-for': '10.0.0.1' }, + }); + + expect(challengeResponse.status).toBe(200); + const challengeXdr = String(challengeResponse.body.challenge ?? ''); + const networkPassphrase = String(challengeResponse.body.network_passphrase ?? ''); + + // Sign the challenge + const challengeTx = new Transaction(challengeXdr, networkPassphrase); + challengeTx.sign(clientKeypair); + const signedChallengeXdr = challengeTx.toXDR(); + + // Get token with custom TTL + const tokenResponse = await customInvoke({ + method: 'POST', + path: '/auth/token', + headers: { 'content-type': 'application/json', 'x-forwarded-for': '10.0.0.1' }, + body: { account: testAccount, challenge: signedChallengeXdr }, + }); + + expect(tokenResponse.status).toBe(200); + expect(tokenResponse.body.expires_in).toBe(7200); + expect(String(tokenResponse.body.token ?? '').length).toBeGreaterThan(0); + + // Cleanup + await customAnchor.shutdown(); + const customDbPath = customDbUrl.startsWith('file:') + ? customDbUrl.slice('file:'.length) + : customDbUrl; + try { + unlinkSync(customDbPath); + } catch { + // ignore cleanup errors + } + }); + + it('4) unauthorized deposit interactive rejected', async () => { + const response = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { 'content-type': 'application/json' }, + body: { asset_code: 'USDC', amount: '10' }, + }); + + expect(response.status).toBe(401); + }); + + it('5) deposit above max_amount is rejected', async () => { + const response = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + }, + body: { asset_code: 'USDC', amount: '101' }, + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_amount'); + expect(response.body.max_amount).toBe(100); + }); + + it('5d) deposit below min_amount is rejected with configured minimum', async () => { + const response = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + }, + body: { asset_code: 'USDC', amount: '9.9' }, + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_amount'); + expect(response.body.min_amount).toBe(10); + expect(response.body.message).toContain('minimum allowed of 10'); + }); + + it('5c) deposit with unknown asset_code is rejected', async () => { + const response = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + }, + body: { asset_code: 'XYZ', amount: '10' }, + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_asset'); + expect(response.body.id).toBeUndefined(); + }); + + it('5e) deposit with deposits_enabled: false asset is rejected', async () => { + const disabledDbUrl = makeSqliteDbUrlForTests(); + const disabledAnchor = createAnchor({ + network: { network: 'testnet' }, + server: { interactiveDomain: 'https://anchor.example.com' }, + security: { + sep10SigningKey: sep10ServerKeypair.secret(), + interactiveJwtSecret: 'jwt-test-secret-disabled', + distributionAccountSecret: 'distribution-test-secret', + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + deposits_enabled: false, + }, + ], + }, + framework: { + database: { provider: 'sqlite', url: disabledDbUrl }, + }, + }); + + await disabledAnchor.init(); + const disabledInvoke = createMountedInvoker(disabledAnchor); + + // Obtain a valid auth token for this anchor instance + const testKeypair = Keypair.random(); + const challengeResponse = await disabledInvoke({ + path: `/auth/challenge?account=${testKeypair.publicKey()}`, + }); + const challengeXdr = String(challengeResponse.body.challenge ?? ''); + const networkPassphrase = String(challengeResponse.body.network_passphrase ?? ''); + const challengeTx = new Transaction(challengeXdr, networkPassphrase); + challengeTx.sign(testKeypair); + const tokenResponse = await disabledInvoke({ + method: 'POST', + path: '/auth/token', + headers: { 'content-type': 'application/json' }, + body: { account: testKeypair.publicKey(), challenge: challengeTx.toXDR() }, + }); + const token = String(tokenResponse.body.token ?? ''); + + const response = await disabledInvoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: { asset_code: 'USDC', amount: '10' }, + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_asset'); + expect(response.body.id).toBeUndefined(); + + await disabledAnchor.shutdown(); + const disabledDbPath = disabledDbUrl.startsWith('file:') + ? disabledDbUrl.slice('file:'.length) + : disabledDbUrl; + try { + unlinkSync(disabledDbPath); + } catch { + // ignore cleanup errors + } + }); + + it('5b) deposit at max_amount boundary is accepted', async () => { + const response = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + 'idempotency-key': 'deposit-boundary', + }, + body: { asset_code: 'USDC', amount: '100' }, + }); + + expect(response.status).toBe(201); + expect(response.body.status).toBe('pending_user_transfer_start'); + }); + + it('6) authorized deposit interactive creates persistent transaction', async () => { + const response = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + 'idempotency-key': 'deposit-1', + }, + body: { asset_code: 'USDC', amount: '25.5' }, + }); + + expect(response.status).toBe(201); + transactionId = String(response.body.id ?? ''); + depositInteractiveUrl = String(response.body.interactive_url ?? ''); + expect(transactionId.length).toBeGreaterThan(0); + expect(response.body.status).toBe('pending_user_transfer_start'); + expect(response.body.asset_issuer).toBe( + 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + ); + expect(response.body).not.toHaveProperty('idempotency_replay'); + }); + + it('6b) deposit with SAME idempotency-key but DIFFERENT body is rejected', async () => { + const response = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + 'idempotency-key': 'deposit-1', // reused key from test 6 + }, + body: { asset_code: 'USDC', amount: '100.0' }, // different amount + }); + + expect(response.status).toBe(409); + expect(response.body.error).toBe('idempotency_conflict'); + }); + + it('6c) idempotent replay returns cached deposit response with replay flag', async () => { + const response = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + 'idempotency-key': 'deposit-1', + }, + body: { asset_code: 'USDC', amount: '25.5' }, + }); + + expect(response.status).toBe(201); + expect(response.body.id).toBe(transactionId); + expect(response.body.interactive_url).toBe(depositInteractiveUrl); + expect(response.body.status).toBe('pending_user_transfer_start'); + expect(response.body.idempotency_replay).toBe(true); + }); + + it('7) transaction lookup fetches persisted data', async () => { + const response = await invoke({ + method: 'GET', + path: `/transactions/${transactionId}`, + headers: { + authorization: `Bearer ${accessToken}`, + }, + }); + + expect(response.status).toBe(200); + expect(response.body.id).toBe(transactionId); + expect(response.body.asset_code).toBe('USDC'); + expect(response.body.asset_issuer).toBe( + 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + ); + expect(response.body.interactive_url).toBe(depositInteractiveUrl); + expect(response.body.interactive_url).toBe( + `https://anchor.example.com/deposit/${transactionId}`, + ); + expect(response.body.more_info_url).toBe(`https://anchor.example.com/deposit/${transactionId}`); + }); + + it('7b) transaction lookup returns 404 for non-existent ID', async () => { + const response = await invoke({ + method: 'GET', + path: '/transactions/non-existent-id-99999', + headers: { + authorization: `Bearer ${accessToken}`, + }, + }); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: 'not_found', message: 'Transaction not found' }); + }); + + it('8) webhook route stores event and invokes configured callback', async () => { + const payload = { + id: 'evt_1', + type: 'deposit.completed', + transaction_id: transactionId, + }; + + const signature = createHmac('sha256', 'webhook-test-secret') + .update(JSON.stringify(payload)) + .digest('hex'); + + const firstResponse = await invoke({ + method: 'POST', + path: '/webhooks/events', + headers: { + 'content-type': 'application/json', + 'x-webhook-provider': 'generic', + 'x-anchor-signature': signature, + }, + body: payload, + }); + + expect(firstResponse.status).toBe(200); + expect(firstResponse.body.received).toBe(true); + expect(firstResponse.body.duplicate).toBe(false); + expect(firstResponse.body.event_id).toBe('evt_1'); + expect(firstResponse.body.provider).toBe('generic'); + expect(webhookCallbackCount).toBe(1); + + const duplicateResponse = await invoke({ + method: 'POST', + path: '/webhooks/events', + headers: { + 'content-type': 'application/json', + 'x-webhook-provider': 'generic', + 'x-anchor-signature': signature, + }, + body: payload, + }); + + expect(duplicateResponse.status).toBe(200); + expect(duplicateResponse.body.received).toBe(true); + expect(duplicateResponse.body.duplicate).toBe(true); + expect(duplicateResponse.body.event_id).toBe('evt_1'); + expect(duplicateResponse.body.provider).toBe('generic'); + expect(webhookCallbackCount).toBe(1); + }); + + it('8b) unsigned webhook is accepted when signature verification is disabled', async () => { + const customDbUrl = makeSqliteDbUrlForTests(); + let unsignedWebhookCallbackCount = 0; + + const customAnchor = createAnchor({ + network: { network: 'testnet' }, + server: { interactiveDomain: 'https://anchor.example.com' }, + security: { + sep10SigningKey: sep10ServerKeypair.secret(), + interactiveJwtSecret: 'jwt-test-secret-webhook-unsigned', + distributionAccountSecret: 'distribution-test-secret', + verifyWebhookSignatures: false, + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + deposits_enabled: true, + }, + ], + }, + framework: { + database: { + provider: 'sqlite', + url: customDbUrl, + }, + }, + webhooks: { + onEvent: async () => { + unsignedWebhookCallbackCount += 1; + }, + }, + }); + + await customAnchor.init(); + const customInvoke = createMountedInvoker(customAnchor); + + const payload = { + id: 'evt_unsigned', + type: 'deposit.completed', + transaction_id: 'tx_unsigned', + }; + + const response = await customInvoke({ + method: 'POST', + path: '/webhooks/events', + headers: { + 'content-type': 'application/json', + 'x-webhook-provider': 'generic', + }, + body: payload, + }); + + expect(response.status).toBe(200); + expect(response.body.duplicate).toBe(false); + expect(unsignedWebhookCallbackCount).toBe(1); + + await customAnchor.shutdown(); + const customDbPath = customDbUrl.startsWith('file:') + ? customDbUrl.slice('file:'.length) + : customDbUrl; + try { + unlinkSync(customDbPath); + } catch { + // ignore cleanup errors + } + }); + + it('8b) webhook route uses default provider when no header provided', async () => { + const payload = { + id: 'evt_2', + type: 'deposit.completed', + transaction_id: transactionId, + }; + + const signature = createHmac('sha256', 'webhook-test-secret') + .update(JSON.stringify(payload)) + .digest('hex'); + + const response = await invoke({ + method: 'POST', + path: '/webhooks/events', + headers: { + 'content-type': 'application/json', + 'x-anchor-signature': signature, + // No x-webhook-provider header + }, + body: payload, + }); + + expect(response.status).toBe(200); + expect(response.body.received).toBe(true); + expect(response.body.duplicate).toBe(false); + expect(response.body.event_id).toBe('evt_2'); + expect(response.body.provider).toBe('generic'); // Should default to 'generic' + }); + + it('8d) webhook without id field returns a generated event_id', async () => { + const payload = { + type: 'deposit.completed', + transaction_id: transactionId, + // Note: No id field + }; + + const signature = createHmac('sha256', 'webhook-test-secret') + .update(JSON.stringify(payload)) + .digest('hex'); + + const response = await invoke({ + method: 'POST', + path: '/webhooks/events', + headers: { + 'content-type': 'application/json', + 'x-webhook-provider': 'generic', + 'x-anchor-signature': signature, + }, + body: payload, + }); + + expect(response.status).toBe(200); + expect(response.body.received).toBe(true); + expect(response.body.duplicate).toBe(false); + expect(typeof response.body.event_id).toBe('string'); + expect((response.body.event_id as string).length).toBeGreaterThan(0); + }); + + it('8e) webhook success response includes received_at ISO timestamp', async () => { + const payload = { + id: 'evt_received_at_check', + type: 'deposit.completed', + transaction_id: transactionId, + }; + + const signature = createHmac('sha256', 'webhook-test-secret') + .update(JSON.stringify(payload)) + .digest('hex'); + + const response = await invoke({ + method: 'POST', + path: '/webhooks/events', + headers: { + 'content-type': 'application/json', + 'x-webhook-provider': 'generic', + 'x-anchor-signature': signature, + }, + body: payload, + }); + + expect(response.status).toBe(200); + expect(response.body.received).toBe(true); + expect(typeof response.body.received_at).toBe('string'); + const parsed = Date.parse(response.body.received_at as string); + expect(Number.isNaN(parsed)).toBe(false); + }); + + it('9) queue worker/watcher processes at least one watch task', async () => { + await new Promise((resolve) => setTimeout(resolve, 125)); + const processed = await anchor.getProcessedWatcherTaskCount(); + expect(processed).toBeGreaterThan(0); + }); + + it('10) unsigned challenge is rejected', async () => { + const account = clientKeypair.publicKey(); + const challengeResponse = await invoke({ + path: `/auth/challenge?account=${account}`, + headers: { 'x-forwarded-for': '10.0.0.2' }, + }); + const challengeXdr = String(challengeResponse.body.challenge ?? ''); + + const tokenResponse = await invoke({ + method: 'POST', + path: '/auth/token', + headers: { 'content-type': 'application/json', 'x-forwarded-for': '10.0.0.2' }, + body: { account, challenge: challengeXdr }, + }); + + expect(tokenResponse.status).toBe(401); + expect(tokenResponse.body.error).toBe('invalid_challenge'); + }); + + it('10a) expired challenge is rejected during token exchange', async () => { + const account = clientKeypair.publicKey(); + const initialNow = new Date('2026-01-01T00:00:00.000Z').getTime(); + const dateNowSpy = vi.spyOn(Date, 'now'); + dateNowSpy.mockReturnValue(initialNow); + + try { + const challengeResponse = await invoke({ + path: `/auth/challenge?account=${account}`, + headers: { 'x-forwarded-for': '10.0.0.12' }, + }); + + expect(challengeResponse.status).toBe(200); + const challengeXdr = String(challengeResponse.body.challenge ?? ''); + const networkPassphrase = String(challengeResponse.body.network_passphrase ?? ''); + const challengeTx = new Transaction(challengeXdr, networkPassphrase); + challengeTx.sign(clientKeypair); + + dateNowSpy.mockReturnValue(initialNow + 301_000); + + const tokenResponse = await invoke({ + method: 'POST', + path: '/auth/token', + headers: { 'content-type': 'application/json', 'x-forwarded-for': '10.0.0.12' }, + body: { account, challenge: challengeTx.toXDR() }, + }); + + expect(tokenResponse.status).toBe(401); + expect(tokenResponse.body.error).toBe('invalid_challenge'); + expect(tokenResponse.body.message).toBe('Challenge expired'); + expect(tokenResponse.body).not.toHaveProperty('access_token'); + } finally { + dateNowSpy.mockRestore(); + } + }); + + it('10b) token with missing/incorrect scope is rejected', async () => { + // Manually sign a token with a different scope to test the server's validation + const jwt = (await import('jsonwebtoken')).default; + const badToken = jwt.sign( + { + sub: clientKeypair.publicKey(), + scope: 'wrong_api', + typ: 'access_token', + }, + 'jwt-test-secret', + { expiresIn: 3600 }, + ); + + const response = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${badToken}`, + }, + body: { asset_code: 'USDC', amount: '10' }, + }); + + expect(response.status).toBe(401); + expect(response.body.error).toBe('unauthorized'); + }); + + it('10d) token with missing/incorrect typ is rejected', async () => { + // Manually sign a token with a different scope to test the server's validation + const jwt = (await import('jsonwebtoken')).default; + const badToken = jwt.sign( + { + sub: clientKeypair.publicKey(), + scope: 'anchor_api', + // typ is missing + }, + 'jwt-test-secret', + { expiresIn: 3600 }, + ); + + const response = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${badToken}`, + }, + body: { asset_code: 'USDC', amount: '10' }, + }); + + expect(response.status).toBe(401); + expect(response.body.error).toBe('unauthorized'); + }); + + it('10c) malformed challenge XDR is rejected', async () => { + const account = clientKeypair.publicKey(); + const invalidChallengeXdr = 'AAAAinvalid_xdr_string_that_is_not_a_valid_transaction'; + + const tokenResponse = await invoke({ + method: 'POST', + path: '/auth/token', + headers: { 'content-type': 'application/json', 'x-forwarded-for': '10.0.0.3' }, + body: { account, challenge: invalidChallengeXdr }, + }); + + expect(tokenResponse.status).toBe(401); + expect(tokenResponse.body.error).toBe('invalid_challenge'); + expect(tokenResponse.body.message).toBe('Challenge transaction is invalid'); + }); + + it('11) reused challenge rejection', async () => { + const account = clientKeypair.publicKey(); + const challengeResponse = await invoke({ + path: `/auth/challenge?account=${account}`, + headers: { 'x-forwarded-for': '10.0.0.4' }, + }); + expect(challengeResponse.status).toBe(200); + const challengeXdr = String(challengeResponse.body.challenge ?? ''); + const networkPassphrase = String(challengeResponse.body.network_passphrase ?? ''); + const challengeTx = new Transaction(challengeXdr, networkPassphrase); + challengeTx.sign(clientKeypair); + const signedChallengeXdr = challengeTx.toXDR(); + + // First exchange succeeds + const firstResponse = await invoke({ + method: 'POST', + path: '/auth/token', + headers: { 'content-type': 'application/json', 'x-forwarded-for': '10.0.0.4' }, + body: { account, challenge: signedChallengeXdr }, + }); + expect(firstResponse.status).toBe(200); + + // Second exchange with same challenge fails + const secondResponse = await invoke({ + method: 'POST', + path: '/auth/token', + headers: { 'content-type': 'application/json', 'x-forwarded-for': '10.0.0.4' }, + body: { account, challenge: signedChallengeXdr }, + }); + + expect(secondResponse.status).toBe(401); + expect(secondResponse.body.error).toBe('invalid_challenge'); + expect(secondResponse.body.message).toBe('Challenge already used'); + }); + + it('12) deposit idempotency replay returns original response', async () => { + const asset_code = 'USDC'; + const amount = '15.0'; + const firstResponse = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + 'idempotency-key': 'replay-test-key', + }, + body: { asset_code, amount }, + }); + + expect(firstResponse.status).toBe(201); + const firstTxId = firstResponse.body.id; + + const secondResponse = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + 'idempotency-key': 'replay-test-key', + }, + body: { asset_code, amount }, + }); + + expect(secondResponse.status).toBe(201); + expect(secondResponse.body.id).toBe(firstTxId); + }); + + it('13) cross-account transaction lookup is rejected', async () => { + // Create a new account and get its token + const otherAccountKeypair = Keypair.random(); + const account = otherAccountKeypair.publicKey(); + const challengeResponse = await invoke({ + path: `/auth/challenge?account=${account}`, + }); + const challengeXdr = String(challengeResponse.body.challenge ?? ''); + const networkPassphrase = String(challengeResponse.body.network_passphrase ?? ''); + const challengeTx = new Transaction(challengeXdr, networkPassphrase); + challengeTx.sign(otherAccountKeypair); + const signedChallengeXdr = challengeTx.toXDR(); + + const tokenResponse = await invoke({ + method: 'POST', + path: '/auth/token', + headers: { 'content-type': 'application/json' }, + body: { account, challenge: signedChallengeXdr }, + }); + const otherAccessToken = String(tokenResponse.body.token ?? ''); + + // Now attempt to look up the transaction from another account + // transactionId was created in test #6 and belongs to clientKeypair + const response = await invoke({ + method: 'GET', + path: `/transactions/${transactionId}`, + headers: { + authorization: `Bearer ${otherAccessToken}`, + }, + }); + + expect(response.status).toBe(403); + expect(response.body.error).toBe('forbidden'); + }); + + it('14) account mismatch during token exchange is rejected', async () => { + const account = clientKeypair.publicKey(); + const otherAccountKeypair = Keypair.random(); + const otherAccount = otherAccountKeypair.publicKey(); + + // Get challenge for 'account' + const challengeResponse = await invoke({ + path: `/auth/challenge?account=${account}`, + }); + expect(challengeResponse.status).toBe(200); + const challengeXdr = String(challengeResponse.body.challenge ?? ''); + const networkPassphrase = String(challengeResponse.body.network_passphrase ?? ''); + + // Sign with 'otherAccount' keypair (mismatched vs the challenge's DB entry) + const challengeTx = new Transaction(challengeXdr, networkPassphrase); + challengeTx.sign(otherAccountKeypair); + const signedChallengeXdr = challengeTx.toXDR(); + + // Submit with 'otherAccount' in the body + const tokenResponse = await invoke({ + method: 'POST', + path: '/auth/token', + headers: { 'content-type': 'application/json' }, + body: { account: otherAccount, challenge: signedChallengeXdr }, + }); + + // Should be rejected because the account in the body (and signature) + // doesn't match the one the challenge was generated for in the DB. + expect(tokenResponse.status).toBe(401); + expect(tokenResponse.body.error).toBe('invalid_challenge'); + expect(tokenResponse.body.message).toBe('Challenge not found'); + }); +}); diff --git a/tests/readme-webhook-raw-body.test.ts b/tests/readme-webhook-raw-body.test.ts new file mode 100644 index 0000000..718b99e --- /dev/null +++ b/tests/readme-webhook-raw-body.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +describe('README webhook raw body guidance', () => { + it('documents Express raw body capture for webhook signature verification', () => { + const readmePath = new URL('../README.md', import.meta.url); + const readme = readFileSync(readmePath, 'utf8'); + + expect(readme).toContain('Webhook raw body capture'); + expect(readme).toContain('express.json'); + expect(readme).toContain('verify'); + expect(readme).toContain('rawBody'); + expect(readme).toContain('exact request body bytes'); + expect(readme).toContain('anchor.getExpressRouter()'); + }); +}); diff --git a/tests/runtime-rate-limiter.test.ts b/tests/runtime-rate-limiter.test.ts new file mode 100644 index 0000000..68def15 --- /dev/null +++ b/tests/runtime-rate-limiter.test.ts @@ -0,0 +1,52 @@ +import { InMemoryRateLimiter } from '@/runtime/http/rate-limiter.ts'; +import { describe, expect, it } from 'vitest'; + +describe('InMemoryRateLimiter', () => { + it('blocks requests after limit within window', () => { + const limiter = new InMemoryRateLimiter(); + const rule = { windowMs: 60000, max: 2 }; + + const first = limiter.hit('auth:127.0.0.1', rule); + const second = limiter.hit('auth:127.0.0.1', rule); + const third = limiter.hit('auth:127.0.0.1', rule); + + expect(first.allowed).toBe(true); + expect(second.allowed).toBe(true); + expect(third.allowed).toBe(false); + expect(third.retryAfterSeconds).toBeGreaterThan(0); + }); + + it('allows requests again after the window resets', () => { + const limiter = new InMemoryRateLimiter(); + const rule = { windowMs: 100, max: 1 }; + + const first = limiter.hit('auth:127.0.0.1', rule); + expect(first.allowed).toBe(true); + + const blocked = limiter.hit('auth:127.0.0.1', rule); + expect(blocked.allowed).toBe(false); + + // Advance time past the window by stubbing Date.now + const originalNow = Date.now; + Date.now = () => originalNow() + 150; + try { + const afterReset = limiter.hit('auth:127.0.0.1', rule); + expect(afterReset.allowed).toBe(true); + } finally { + Date.now = originalNow; + } + }); + + it('isolates rate limits per key', () => { + const limiter = new InMemoryRateLimiter(); + const rule = { windowMs: 60000, max: 1 }; + + const firstKey = limiter.hit('auth:127.0.0.1', rule); + const firstKeyBlocked = limiter.hit('auth:127.0.0.1', rule); + const secondKey = limiter.hit('auth:192.168.1.1', rule); + + expect(firstKey.allowed).toBe(true); + expect(firstKeyBlocked.allowed).toBe(false); + expect(secondKey.allowed).toBe(true); + }); +}); diff --git a/tests/runtime/queue.unit.test.ts b/tests/runtime/queue.unit.test.ts new file mode 100644 index 0000000..571adbf --- /dev/null +++ b/tests/runtime/queue.unit.test.ts @@ -0,0 +1,312 @@ +import type { QueueJob } from '@/runtime/interfaces.ts'; +import { InMemoryQueueAdapter } from '@/runtime/queue/in-memory-queue.ts'; +import { describe, expect, it } from 'vitest'; + +describe('InMemoryQueueAdapter', () => { + it('should honor concurrency limits when processing jobs', async () => { + const concurrency = 2; + const queue = new InMemoryQueueAdapter({ concurrency }); + + // Track concurrent execution + let maxConcurrentJobs = 0; + let currentConcurrentJobs = 0; + + // Create a worker that tracks concurrency + const worker = async (_job: QueueJob): Promise => { + currentConcurrentJobs++; + maxConcurrentJobs = Math.max(maxConcurrentJobs, currentConcurrentJobs); + + // Simulate async work + await new Promise((resolve) => setTimeout(resolve, 50)); + + currentConcurrentJobs--; + }; + + // Start the queue + await queue.start(worker); + + // Enqueue more jobs than the concurrency limit + const totalJobs = 6; + for (let i = 0; i < totalJobs; i++) { + const job: QueueJob = { + type: 'process_watcher_task', + payload: { jobId: i }, + }; + await queue.enqueue(job); + } + + // Wait for all jobs to complete + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Verify that concurrency was never exceeded + expect(maxConcurrentJobs).toBeLessThanOrEqual(concurrency); + expect(maxConcurrentJobs).toBeGreaterThan(0); // Ensure jobs actually ran + + await queue.stop(); + }); + + it('should process jobs sequentially when concurrency is 1', async () => { + const concurrency = 1; + const queue = new InMemoryQueueAdapter({ concurrency }); + + const executionOrder: number[] = []; + + const worker = async (job: QueueJob): Promise => { + const jobId = job.payload.jobId as number; + executionOrder.push(jobId); + + // Simulate work to ensure overlapping execution would be detectable + await new Promise((resolve) => setTimeout(resolve, 30)); + }; + + await queue.start(worker); + + // Enqueue multiple jobs + const totalJobs = 4; + for (let i = 0; i < totalJobs; i++) { + const job: QueueJob = { + type: 'process_watcher_task', + payload: { jobId: i }, + }; + await queue.enqueue(job); + } + + // Wait for all jobs to complete + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Verify sequential execution (order should match enqueue order for concurrency=1) + expect(executionOrder).toEqual([0, 1, 2, 3]); + + await queue.stop(); + }); + + it('should allow concurrent execution up to the limit', async () => { + const concurrency = 3; + const queue = new InMemoryQueueAdapter({ concurrency }); + + let maxConcurrentJobs = 0; + let currentConcurrentJobs = 0; + const startTimes: number[] = []; + + const worker = async (job: QueueJob): Promise => { + currentConcurrentJobs++; + maxConcurrentJobs = Math.max(maxConcurrentJobs, currentConcurrentJobs); + + const jobId = job.payload.jobId as number; + startTimes[jobId] = Date.now(); + + // Simulate work + await new Promise((resolve) => setTimeout(resolve, 50)); + + currentConcurrentJobs--; + }; + + await queue.start(worker); + + // Enqueue jobs that should be able to run concurrently + const totalJobs = 5; + for (let i = 0; i < totalJobs; i++) { + const job: QueueJob = { + type: 'process_watcher_task', + payload: { jobId: i }, + }; + await queue.enqueue(job); + } + + // Wait for all jobs to complete + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Verify that the concurrency limit was reached but not exceeded + expect(maxConcurrentJobs).toBe(concurrency); + expect(maxConcurrentJobs).toBeGreaterThan(0); + + await queue.stop(); + }); + + it('should process jobs queued before start() after start() is called', async () => { + const queue = new InMemoryQueueAdapter({ concurrency: 1 }); + const processedJobs: number[] = []; + + const worker = async (job: QueueJob): Promise => { + processedJobs.push(job.payload.jobId as number); + }; + + // Enqueue jobs BEFORE start() + const jobsToEnqueue = [1, 2, 3]; + for (const jobId of jobsToEnqueue) { + await queue.enqueue({ + type: 'process_watcher_task', + payload: { jobId }, + }); + } + + // Verify no jobs processed yet + expect(processedJobs).toHaveLength(0); + + // Start the queue + await queue.start(worker); + + // Wait for jobs to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify all jobs were processed in order + expect(processedJobs).toEqual(jobsToEnqueue); + + await queue.stop(); + }); + + it('should wait for in-flight jobs to complete when stop() is called', async () => { + const queue = new InMemoryQueueAdapter({ concurrency: 2 }); + let completedJobs = 0; + + const worker = async (_job: QueueJob): Promise => { + // Simulate work + await new Promise((resolve) => setTimeout(resolve, 50)); + completedJobs++; + }; + + await queue.start(worker); + + // Enqueue 4 jobs + for (let i = 0; i < 4; i++) { + await queue.enqueue({ + type: 'process_watcher_task', + payload: { i }, + }); + } + + // Call stop() immediately. Concurrency is 2, so 2 jobs should have started. + // stop() should wait for these 2 jobs to finish. + await queue.stop(); + + // Verify that exactly 2 jobs were completed (the ones that started) + expect(completedJobs).toBe(2); + }); + + it('should not start new jobs after stop() is called', async () => { + const queue = new InMemoryQueueAdapter({ concurrency: 1 }); + const startedJobs: number[] = []; + const completedJobs: number[] = []; + + const worker = async (job: QueueJob): Promise => { + const id = job.payload.i as number; + startedJobs.push(id); + await new Promise((resolve) => setTimeout(resolve, 50)); + completedJobs.push(id); + }; + + await queue.start(worker); + + // Enqueue 3 jobs + for (let i = 0; i < 3; i++) { + await queue.enqueue({ + type: 'process_watcher_task', + payload: { i }, + }); + } + + // Call stop() + await queue.stop(); + + // Only the first job should have started and completed because concurrency is 1 + expect(startedJobs).toEqual([0]); + expect(completedJobs).toEqual([0]); + + // Wait a bit more to be sure no other jobs start + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(startedJobs).toEqual([0]); + }); + + it('should handle multiple calls to stop() correctly', async () => { + const queue = new InMemoryQueueAdapter({ concurrency: 2 }); + let completedJobs = 0; + + const worker = async (_job: QueueJob): Promise => { + await new Promise((resolve) => setTimeout(resolve, 50)); + completedJobs++; + }; + + await queue.start(worker); + await queue.enqueue({ type: 'process_watcher_task', payload: {} }); + + // Call stop() multiple times + const p1 = queue.stop(); + const p2 = queue.stop(); + const p3 = queue.stop(); + + await Promise.all([p1, p2, p3]); + + expect(completedJobs).toBe(1); + }); + + it('should not start new jobs even if stop() is called while kick() is running', async () => { + const queue = new InMemoryQueueAdapter({ concurrency: 2 }); + const startedJobs: number[] = []; + + const worker = async (job: QueueJob): Promise => { + const id = job.payload.i as number; + startedJobs.push(id); + // When the first job starts, call stop() + if (id === 0) { + await queue.stop(); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + }; + + await queue.start(worker); + + // Enqueue 3 jobs + for (let i = 0; i < 3; i++) { + await queue.enqueue({ + type: 'process_watcher_task', + payload: { i }, + }); + } + + // Wait for all to finish + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Even though concurrency is 2, job 1 should not start because stop() was called when job 0 started + // Actually, in our implementation, kick() starts jobs synchronously in a loop. + // If job 0's worker is called, it's an async call, so it returns a promise. + // The loop continues and starts job 1. + // However, if worker(0) called stop() *synchronously*, it might prevent job 1. + // But our worker is async, so it calls queue.stop() after an await or in its body. + + // Let's re-verify the behavior. + // If worker is: + // const worker = async (job) => { + // if (job.id === 0) queue.stop(); + // } + // kick() does: + // while (...) { + // worker(job); // this returns a promise immediately + // } + // So both job 0 and job 1 will start before queue.stop() is ever called. + + // BUT, if we want to ensure no *new* jobs start *after* stop() is called: + expect(startedJobs.length).toBeLessThanOrEqual(2); + }); + + it('should resolve stop() only after the very last job is completely finished', async () => { + const queue = new InMemoryQueueAdapter({ concurrency: 1 }); + let jobFinished = false; + + const worker = async (_job: QueueJob): Promise => { + await new Promise((resolve) => setTimeout(resolve, 100)); + jobFinished = true; + }; + + await queue.start(worker); + await queue.enqueue({ type: 'process_watcher_task', payload: {} }); + + // Ensure job has started + await new Promise((resolve) => setTimeout(resolve, 10)); + + const stopPromise = queue.stop(); + expect(jobFinished).toBe(false); // Job should still be running + + await stopPromise; + expect(jobFinished).toBe(true); // stop() should only resolve after job is finished + }); +}); diff --git a/tests/runtime/sql-adapter-cleanup.test.ts b/tests/runtime/sql-adapter-cleanup.test.ts new file mode 100644 index 0000000..848115f --- /dev/null +++ b/tests/runtime/sql-adapter-cleanup.test.ts @@ -0,0 +1,150 @@ +import { makeSqliteDbUrlForTests } from '@/core/factory.ts'; +import { createSqlDatabaseAdapter } from '@/runtime/database/sql-database-adapter.ts'; +import type { DatabaseAdapter } from '@/runtime/interfaces.ts'; +import { Database } from 'bun:sqlite'; +import { randomUUID } from 'node:crypto'; +import { unlinkSync } from 'node:fs'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { + const dbUrl = makeSqliteDbUrlForTests(); + const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice('file:'.length) : dbUrl; + let db: DatabaseAdapter; + let raw: Database; + + const CUTOFF = '2024-06-01T12:00:00.000Z'; + const BEFORE = '2024-01-01T00:00:00.000Z'; + const AFTER = '2025-01-01T00:00:00.000Z'; + + beforeAll(async () => { + db = createSqlDatabaseAdapter({ provider: 'sqlite', url: dbUrl }); + await db.connect(); + await db.migrate(); + raw = new Database(dbPath); + }); + + afterAll(async () => { + raw.close(); + await db.disconnect(); + try { + unlinkSync(dbPath); + } catch { + // ignore + } + }); + + it('removes expired operational rows and leaves rows that are not cleanup-eligible', async () => { + const challengeExpired = `challenge-expired-${randomUUID()}`; + const challengeKept = `challenge-kept-${randomUUID()}`; + + await db.insertAuthChallenge({ + id: randomUUID(), + account: 'GEXPIRED', + challenge: challengeExpired, + expiresAt: BEFORE, + }); + await db.insertAuthChallenge({ + id: randomUUID(), + account: 'GKEPT', + challenge: challengeKept, + expiresAt: AFTER, + }); + + const idemOldId = randomUUID(); + const idemNewId = randomUUID(); + const scope = `scope-${randomUUID()}`; + raw + .prepare( + `INSERT INTO idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(idemOldId, scope, 'old-key', 'hash-a', 200, '{}', BEFORE); + raw + .prepare( + `INSERT INTO idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(idemNewId, scope, 'new-key', 'hash-b', 200, '{}', AFTER); + + const whOldProcessedId = randomUUID(); + const whOldPendingId = randomUUID(); + const whNewProcessedId = randomUUID(); + const payload = '{}'; + + raw + .prepare( + `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, ?, ?, ?, 'processed', NULL, ?, ?)`, + ) + .run(whOldProcessedId, `evt-op-${randomUUID()}`, 'test', payload, BEFORE, BEFORE); + raw + .prepare( + `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, ?, ?, ?, 'pending', NULL, NULL, ?)`, + ) + .run(whOldPendingId, `evt-pend-${randomUUID()}`, 'test', payload, BEFORE); + raw + .prepare( + `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, ?, ?, ?, 'processed', NULL, ?, ?)`, + ) + .run(whNewProcessedId, `evt-new-${randomUUID()}`, 'test', payload, AFTER, AFTER); + + const wOldProcessedId = randomUUID(); + const wOldPendingId = randomUUID(); + const wNewProcessedId = randomUUID(); + const taskPayload = '{}'; + + raw + .prepare( + `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + VALUES (?, 'w', ?, 'processed', NULL, ?, ?)`, + ) + .run(wOldProcessedId, taskPayload, BEFORE, BEFORE); + raw + .prepare( + `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + VALUES (?, 'w', ?, 'pending', NULL, NULL, ?)`, + ) + .run(wOldPendingId, taskPayload, BEFORE); + raw + .prepare( + `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + VALUES (?, 'w', ?, 'processed', NULL, ?, ?)`, + ) + .run(wNewProcessedId, taskPayload, AFTER, AFTER); + + await db.cleanupOldRecords(CUTOFF); + + expect(await db.getAuthChallengeByChallenge(challengeExpired)).toBeNull(); + const keptAuth = await db.getAuthChallengeByChallenge(challengeKept); + expect(keptAuth).not.toBeNull(); + + expect(await db.getIdempotencyRecord(scope, 'old-key')).toBeNull(); + expect(await db.getIdempotencyRecord(scope, 'new-key')).not.toBeNull(); + + const webhookCount = (id: string) => + Number( + ( + raw.prepare('SELECT COUNT(*) AS c FROM webhook_events WHERE id = ?').get(id) as { + c: number; + } + ).c, + ); + expect(webhookCount(whOldProcessedId)).toBe(0); + expect(webhookCount(whOldPendingId)).toBe(1); + expect(webhookCount(whNewProcessedId)).toBe(1); + + const watcherCount = (id: string) => + Number( + ( + raw.prepare('SELECT COUNT(*) AS c FROM watcher_tasks WHERE id = ?').get(id) as { + c: number; + } + ).c, + ); + expect(watcherCount(wOldProcessedId)).toBe(0); + expect(watcherCount(wOldPendingId)).toBe(1); + expect(watcherCount(wNewProcessedId)).toBe(1); + }); +}); diff --git a/tests/runtime/sql-adapter-interactive-tx.test.ts b/tests/runtime/sql-adapter-interactive-tx.test.ts new file mode 100644 index 0000000..48f0723 --- /dev/null +++ b/tests/runtime/sql-adapter-interactive-tx.test.ts @@ -0,0 +1,68 @@ +import { makeSqliteDbUrlForTests } from '@/core/factory.ts'; +import { createSqlDatabaseAdapter } from '@/runtime/database/sql-database-adapter.ts'; +import { randomUUID } from 'node:crypto'; +import { unlinkSync } from 'node:fs'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { DatabaseAdapter } from '@/runtime/interfaces.ts'; + +describe('SqlDatabaseAdapter – interactive transaction status updates', () => { + const dbUrl = makeSqliteDbUrlForTests(); + const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice('file:'.length) : dbUrl; + let db: DatabaseAdapter; + + beforeAll(async () => { + db = createSqlDatabaseAdapter({ provider: 'sqlite', url: dbUrl }); + await db.connect(); + await db.migrate(); + }); + + afterAll(async () => { + await db.disconnect(); + try { + unlinkSync(dbPath); + } catch { + // ignore + } + }); + + it('updates status and reflects the change on fetch', async () => { + const txId = randomUUID(); + const RealDate = Date; + let currentTime = new RealDate('2026-01-01T00:00:00.000Z').getTime(); + + class MockDate extends RealDate { + constructor(value?: string | number | Date) { + super(value === undefined ? currentTime : value); + } + + static override now(): number { + return currentTime; + } + } + + globalThis.Date = MockDate as DateConstructor; + + try { + const inserted = await db.insertInteractiveTransaction({ + id: txId, + account: 'GTEST1234', + kind: 'deposit', + assetCode: 'USDC', + amount: '50.00', + status: 'pending_user_transfer_start', + }); + + expect(inserted.status).toBe('pending_user_transfer_start'); + + currentTime = new RealDate('2026-01-01T00:00:01.000Z').getTime(); + await db.updateTransactionStatus(txId, 'completed'); + + const fetched = await db.getInteractiveTransactionById(txId); + expect(fetched).not.toBeNull(); + expect(fetched!.status).toBe('completed'); + expect(fetched!.updatedAt).not.toBe(inserted.updatedAt); + } finally { + globalThis.Date = RealDate; + } + }); +}); diff --git a/tests/runtime/sql-adapter-watcher-tasks.test.ts b/tests/runtime/sql-adapter-watcher-tasks.test.ts new file mode 100644 index 0000000..cb1502c --- /dev/null +++ b/tests/runtime/sql-adapter-watcher-tasks.test.ts @@ -0,0 +1,134 @@ +import { makeSqliteDbUrlForTests } from '@/core/factory.ts'; +import { createSqlDatabaseAdapter } from '@/runtime/database/sql-database-adapter.ts'; +import { randomUUID } from 'node:crypto'; +import { unlinkSync } from 'node:fs'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { DatabaseAdapter } from '@/runtime/interfaces.ts'; + +describe('SqlDatabaseAdapter – watcher task persistence and processed counts', () => { + const dbUrl = makeSqliteDbUrlForTests(); + const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice('file:'.length) : dbUrl; + let db: DatabaseAdapter; + + beforeAll(async () => { + db = createSqlDatabaseAdapter({ provider: 'sqlite', url: dbUrl }); + await db.connect(); + await db.migrate(); + }); + + afterAll(async () => { + await db.disconnect(); + try { + unlinkSync(dbPath); + } catch { + // ignore + } + }); + + beforeEach(async () => { + // Clean up watcher tasks between tests + const sqlite = (db as unknown as { sqlite?: { exec: (sql: string) => void } }).sqlite; + if (sqlite) { + sqlite.exec('DELETE FROM watcher_tasks'); + } + }); + + afterEach(async () => { + // Ensure cleanup after each test + const sqlite = (db as unknown as { sqlite?: { exec: (sql: string) => void } }).sqlite; + if (sqlite) { + sqlite.exec('DELETE FROM watcher_tasks'); + } + }); + + it('inserts a watcher task, updates status to processed, and counts it correctly', async () => { + const taskId = randomUUID(); + const watcherName = 'test-watcher'; + const payload = { foo: 'bar', count: 42 }; + + // Insert a new watcher task + await db.insertWatcherTask({ + id: taskId, + watcherName, + payload, + }); + + // Verify initial count is 0 (task is pending) + let processedCount = await db.countProcessedWatcherTasks(); + expect(processedCount).toBe(0); + + // List pending tasks to verify the task exists + const pendingTasks = await db.listPendingWatcherTasks(10); + expect(pendingTasks).toHaveLength(1); + expect(pendingTasks[0].id).toBe(taskId); + expect(pendingTasks[0].watcherName).toBe(watcherName); + expect(pendingTasks[0].payload).toEqual(payload); + expect(pendingTasks[0].status).toBe('pending'); + + // Update the task status to processed + await db.updateWatcherTaskStatus({ + id: taskId, + status: 'processed', + }); + + // Verify the processed count is now 1 + processedCount = await db.countProcessedWatcherTasks(); + expect(processedCount).toBe(1); + + // Verify the task is no longer in pending list + const stillPending = await db.listPendingWatcherTasks(10); + expect(stillPending).toHaveLength(0); + }); + + it('handles multiple watcher tasks with mixed statuses', async () => { + const task1Id = randomUUID(); + const task2Id = randomUUID(); + const task3Id = randomUUID(); + + // Insert three tasks + await db.insertWatcherTask({ + id: task1Id, + watcherName: 'multi-task-watcher', + payload: { task: 1 }, + }); + await db.insertWatcherTask({ + id: task2Id, + watcherName: 'multi-task-watcher', + payload: { task: 2 }, + }); + await db.insertWatcherTask({ + id: task3Id, + watcherName: 'multi-task-watcher', + payload: { task: 3 }, + }); + + // Initially all are pending + expect(await db.countProcessedWatcherTasks()).toBe(0); + expect((await db.listPendingWatcherTasks(10)).length).toBe(3); + + // Mark task1 and task3 as processed + await db.updateWatcherTaskStatus({ id: task1Id, status: 'processed' }); + await db.updateWatcherTaskStatus({ id: task3Id, status: 'processed' }); + + // Count should be 2 + expect(await db.countProcessedWatcherTasks()).toBe(2); + + // Only task2 should remain pending + const pending = await db.listPendingWatcherTasks(10); + expect(pending).toHaveLength(1); + expect(pending[0].id).toBe(task2Id); + + // Mark task2 as failed + await db.updateWatcherTaskStatus({ + id: task2Id, + status: 'failed', + errorMessage: 'Test failure', + }); + + // Count should still be 2 (failed tasks don't count) + expect(await db.countProcessedWatcherTasks()).toBe(2); + + // No pending tasks left + expect(await db.listPendingWatcherTasks(10)).toHaveLength(0); + }); +}); diff --git a/tests/runtime/sql-adapter-webhook-dedupe.test.ts b/tests/runtime/sql-adapter-webhook-dedupe.test.ts new file mode 100644 index 0000000..efc80dc --- /dev/null +++ b/tests/runtime/sql-adapter-webhook-dedupe.test.ts @@ -0,0 +1,97 @@ +import { makeSqliteDbUrlForTests } from '@/core/factory.ts'; +import { createSqlDatabaseAdapter } from '@/runtime/database/sql-database-adapter.ts'; +import type { DatabaseAdapter } from '@/runtime/interfaces.ts'; +import { unlinkSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('SqlDatabaseAdapter – webhook event deduplication (sqlite)', () => { + const dbUrl = makeSqliteDbUrlForTests(); + const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice('file:'.length) : dbUrl; + let db: DatabaseAdapter; + + beforeAll(async () => { + db = createSqlDatabaseAdapter({ provider: 'sqlite', url: dbUrl }); + await db.connect(); + await db.migrate(); + }); + + afterAll(async () => { + await db.disconnect(); + try { + unlinkSync(dbPath); + } catch { + // ignore + } + }); + + it('first insert returns inserted: true with the new record', async () => { + const eventId = `evt-${randomUUID()}`; + const payload = { type: 'payment.completed', amount: '100' }; + + const result = await db.insertWebhookEvent({ + id: randomUUID(), + eventId, + provider: 'test-provider', + payload, + }); + + expect(result.inserted).toBe(true); + expect(result.record.eventId).toBe(eventId); + expect(result.record.provider).toBe('test-provider'); + expect(result.record.status).toBe('pending'); + expect(result.record.payload).toEqual(payload); + expect(result.record.processedAt).toBeNull(); + expect(result.record.errorMessage).toBeNull(); + }); + + it('second insert with same event_id returns inserted: false and the existing record', async () => { + const eventId = `evt-${randomUUID()}`; + const payload = { type: 'payment.completed', amount: '200' }; + const firstId = randomUUID(); + + const first = await db.insertWebhookEvent({ + id: firstId, + eventId, + provider: 'test-provider', + payload, + }); + expect(first.inserted).toBe(true); + + const duplicate = await db.insertWebhookEvent({ + id: randomUUID(), + eventId, + provider: 'test-provider', + payload: { type: 'tampered', amount: '999' }, + }); + + expect(duplicate.inserted).toBe(false); + expect(duplicate.record.id).toBe(firstId); + expect(duplicate.record.eventId).toBe(eventId); + expect(duplicate.record.payload).toEqual(payload); + }); + + it('different event_ids are each inserted independently', async () => { + const eventIdA = `evt-${randomUUID()}`; + const eventIdB = `evt-${randomUUID()}`; + + const resultA = await db.insertWebhookEvent({ + id: randomUUID(), + eventId: eventIdA, + provider: 'test-provider', + payload: { seq: 1 }, + }); + + const resultB = await db.insertWebhookEvent({ + id: randomUUID(), + eventId: eventIdB, + provider: 'test-provider', + payload: { seq: 2 }, + }); + + expect(resultA.inserted).toBe(true); + expect(resultB.inserted).toBe(true); + expect(resultA.record.eventId).toBe(eventIdA); + expect(resultB.record.eventId).toBe(eventIdB); + }); +}); diff --git a/tests/runtime/transaction-watcher.unit.test.ts b/tests/runtime/transaction-watcher.unit.test.ts new file mode 100644 index 0000000..36d1925 --- /dev/null +++ b/tests/runtime/transaction-watcher.unit.test.ts @@ -0,0 +1,235 @@ +import type { DatabaseAdapter, QueueAdapter } from '@/runtime/interfaces.ts'; +import { TransactionWatcher } from '@/runtime/watchers/transaction-watcher.ts'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('TransactionWatcher Unit Tests', () => { + let mockDatabase: DatabaseAdapter; + let mockQueue: QueueAdapter; + let transactionWatcher: TransactionWatcher; + + beforeEach(() => { + mockDatabase = { + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + migrate: vi.fn().mockResolvedValue(undefined), + insertAuthChallenge: vi.fn().mockResolvedValue(undefined), + getAuthChallengeByChallenge: vi.fn().mockResolvedValue(null), + markAuthChallengeConsumed: vi.fn().mockResolvedValue(undefined), + insertInteractiveTransaction: vi.fn().mockResolvedValue({ + id: 'test-tx-id', + account: 'test-account', + kind: 'deposit' as const, + assetCode: 'USDC', + amount: '100', + status: 'pending', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }), + getInteractiveTransactionById: vi.fn().mockResolvedValue(null), + listPendingTransactionsBefore: vi.fn().mockResolvedValue([]), + updateTransactionStatus: vi.fn().mockResolvedValue(undefined), + getIdempotencyRecord: vi.fn().mockResolvedValue(null), + insertIdempotencyRecord: vi.fn().mockResolvedValue(undefined), + insertWebhookEvent: vi.fn().mockResolvedValue({ + inserted: true, + record: { + id: 'webhook-id', + eventId: 'external-id', + provider: 'generic', + payload: {}, + status: 'pending' as const, + errorMessage: null, + processedAt: null, + createdAt: new Date().toISOString(), + }, + }), + updateWebhookEventStatus: vi.fn().mockResolvedValue(undefined), + insertWatcherTask: vi.fn().mockResolvedValue(undefined), + listPendingWatcherTasks: vi.fn().mockResolvedValue([]), + updateWatcherTaskStatus: vi.fn().mockResolvedValue(undefined), + countProcessedWatcherTasks: vi.fn().mockResolvedValue(0), + cleanupOldRecords: vi.fn().mockResolvedValue(undefined), + } as unknown as DatabaseAdapter; + + mockQueue = { + enqueue: vi.fn().mockResolvedValue(undefined), + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + } as unknown as QueueAdapter; + + transactionWatcher = new TransactionWatcher(mockDatabase, mockQueue, { + pollIntervalMs: 1000, + transactionTimeoutMs: 300000, // 5 minutes + retentionDays: 30, + }); + }); + + it('enqueues expiration jobs for stale pending deposits', async () => { + const staleTransaction = { + id: 'stale-tx-1', + account: 'account-1', + kind: 'deposit' as const, + assetCode: 'USDC', + amount: '100', + status: 'pending', + createdAt: new Date(Date.now() - 400000).toISOString(), // 6+ minutes ago (stale) + updatedAt: new Date(Date.now() - 400000).toISOString(), + }; + + const anotherStaleTransaction = { + id: 'stale-tx-2', + account: 'account-2', + kind: 'deposit' as const, + assetCode: 'EURT', + amount: '50', + status: 'pending', + createdAt: new Date(Date.now() - 350000).toISOString(), // 5+ minutes ago (stale) + updatedAt: new Date(Date.now() - 350000).toISOString(), + }; + + mockDatabase.listPendingTransactionsBefore = vi + .fn() + .mockResolvedValue([staleTransaction, anotherStaleTransaction]); + + // Start the watcher to trigger the tick + await transactionWatcher.start(); + + // Should enqueue expire_transaction jobs for each stale transaction + expect(mockQueue.enqueue).toHaveBeenCalledWith({ + type: 'expire_transaction', + payload: { transactionId: 'stale-tx-1' }, + }); + + expect(mockQueue.enqueue).toHaveBeenCalledWith({ + type: 'expire_transaction', + payload: { transactionId: 'stale-tx-2' }, + }); + + // Should have called enqueue at least 4 times: + // 2 for expire_transaction, 1 for process_watcher_task, 1 for cleanup_records + expect(mockQueue.enqueue).toHaveBeenCalledTimes(4); + + // Should have inserted a watcher task record + expect(mockDatabase.insertWatcherTask).toHaveBeenCalledWith({ + id: expect.any(String), + watcherName: 'transaction-watcher', + payload: { + pendingTransactionsChecked: 2, + checkedAt: expect.any(String), + }, + }); + + // Stop the watcher to clean up + await transactionWatcher.stop(); + }); + + it('does not enqueue expiration jobs when no stale pending deposits exist', async () => { + mockDatabase.listPendingTransactionsBefore = vi.fn().mockResolvedValue([]); + + // Start the watcher to trigger the tick + await transactionWatcher.start(); + + // Should not enqueue any expire_transaction jobs + expect(mockQueue.enqueue).not.toHaveBeenCalledWith({ + type: 'expire_transaction', + payload: expect.any(Object), + }); + + // Should still enqueue process_watcher_task and cleanup_records + expect(mockQueue.enqueue).toHaveBeenCalledTimes(2); + + // Should have inserted a watcher task record with zero checked + expect(mockDatabase.insertWatcherTask).toHaveBeenCalledWith({ + id: expect.any(String), + watcherName: 'transaction-watcher', + payload: { + pendingTransactionsChecked: 0, + checkedAt: expect.any(String), + }, + }); + + // Stop the watcher to clean up + await transactionWatcher.stop(); + }); + + it('calculates cutoff time correctly for transaction timeout', async () => { + const now = Date.now(); + const timeoutMs = 300000; // 5 minutes + const expectedCutoff = new Date(now - timeoutMs).toISOString(); + + // Mock Date.now to control timing + const originalDateNow = Date.now; + Date.now = vi.fn().mockReturnValue(now); + + const staleTransaction = { + id: 'stale-tx', + account: 'account', + kind: 'deposit' as const, + assetCode: 'USDC', + amount: '100', + status: 'pending', + createdAt: new Date(now - timeoutMs - 1000).toISOString(), // Just over timeout + updatedAt: new Date(now - timeoutMs - 1000).toISOString(), + }; + + mockDatabase.listPendingTransactionsBefore = vi.fn().mockResolvedValue([staleTransaction]); + + // Start the watcher to trigger the tick + await transactionWatcher.start(); + + // Verify the cutoff time was calculated correctly + expect(mockDatabase.listPendingTransactionsBefore).toHaveBeenCalledWith(expectedCutoff); + + // Restore original Date.now + Date.now = originalDateNow; + + // Stop the watcher to clean up + await transactionWatcher.stop(); + }); + + it('prevents overlapping ticks when called concurrently', async () => { + let resolveTick!: (value: unknown[]) => void; + const tickPromise = new Promise((resolve) => { + resolveTick = resolve; + }); + + mockDatabase.listPendingTransactionsBefore = vi.fn().mockReturnValue(tickPromise); + + // Start two ticks concurrently + // Accessing private method for testing purposes + const watcherWithTick = transactionWatcher as unknown as { tick: () => Promise }; + const tick1 = watcherWithTick.tick(); + const tick2 = watcherWithTick.tick(); + + // Resolve the database call + resolveTick!([]); + + await Promise.all([tick1, tick2]); + + // Database should only be called once because the second tick should have returned early + expect(mockDatabase.listPendingTransactionsBefore).toHaveBeenCalledTimes(1); + }); + + it('enqueues a cleanup_records job with the configured retention days', async () => { + const retentionDays = 45; + const customWatcher = new TransactionWatcher(mockDatabase, mockQueue, { + pollIntervalMs: 1000, + transactionTimeoutMs: 300000, + retentionDays, + }); + + // Start the watcher to trigger one tick + await customWatcher.start(); + + // Assert that the cleanup_records job was enqueued with the correct retentionDays + expect(mockQueue.enqueue).toHaveBeenCalledWith({ + type: 'cleanup_records', + payload: { + retentionDays, + }, + }); + + // Stop the watcher + await customWatcher.stop(); + }); +}); diff --git a/tests/runtime/webhook-processor.unit.test.ts b/tests/runtime/webhook-processor.unit.test.ts new file mode 100644 index 0000000..0c1da6c --- /dev/null +++ b/tests/runtime/webhook-processor.unit.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi } from 'vitest'; +import { DefaultWebhookProcessor } from '@/runtime/webhooks/default-webhook-processor.ts'; +import type { DatabaseAdapter } from '@/runtime/interfaces.ts'; +import type { AnchorKitConfig } from '@/types/config.ts'; + +describe('DefaultWebhookProcessor Unit Tests', () => { + it('updates event status to failed when callback throws', async () => { + const mockDatabase = { + insertWebhookEvent: vi.fn().mockResolvedValue({ + inserted: true, + record: { + id: 'internal-id', + eventId: 'external-id', + provider: 'generic', + payload: {}, + createdAt: new Date().toISOString(), + }, + }), + updateWebhookEventStatus: vi.fn().mockResolvedValue(undefined), + } as unknown as DatabaseAdapter; + + const mockConfig = { + security: { + verifyWebhookSignatures: false, + }, + webhooks: { + onEvent: vi.fn().mockRejectedValue(new Error('Callback failed')), + }, + } as unknown as AnchorKitConfig; + + const processor = new DefaultWebhookProcessor({ + config: mockConfig, + database: mockDatabase, + }); + + const input = { + eventId: 'external-id', + provider: 'generic', + payload: {}, + rawBody: '{}', + }; + + // Should rethrow the error + await expect(processor.process(input)).rejects.toThrow('Callback failed'); + + // Should have updated status to failed with error message + expect(mockDatabase.updateWebhookEventStatus).toHaveBeenCalledWith({ + id: 'internal-id', + status: 'failed', + errorMessage: 'Callback failed', + }); + }); + + it('updates event status to processed when callback succeeds', async () => { + const mockDatabase = { + insertWebhookEvent: vi.fn().mockResolvedValue({ + inserted: true, + record: { + id: 'internal-id-2', + eventId: 'external-id-2', + provider: 'generic', + payload: {}, + createdAt: new Date().toISOString(), + }, + }), + updateWebhookEventStatus: vi.fn().mockResolvedValue(undefined), + } as unknown as DatabaseAdapter; + + const mockConfig = { + security: { + verifyWebhookSignatures: false, + }, + webhooks: { + onEvent: vi.fn().mockResolvedValue(undefined), + }, + } as unknown as AnchorKitConfig; + + const processor = new DefaultWebhookProcessor({ + config: mockConfig, + database: mockDatabase, + }); + + const input = { + eventId: 'external-id-2', + provider: 'generic', + payload: {}, + rawBody: '{}', + }; + + const result = await processor.process(input); + expect(result.duplicate).toBe(false); + + // Should have updated status to processed + expect(mockDatabase.updateWebhookEventStatus).toHaveBeenCalledWith({ + id: 'internal-id-2', + status: 'processed', + }); + }); +}); diff --git a/tests/runtime/webhooks/default-webhook-processor.test.ts b/tests/runtime/webhooks/default-webhook-processor.test.ts new file mode 100644 index 0000000..177bac9 --- /dev/null +++ b/tests/runtime/webhooks/default-webhook-processor.test.ts @@ -0,0 +1,95 @@ +import { DefaultWebhookProcessor } from '../../../src/runtime/webhooks/default-webhook-processor.ts'; +import type { AnchorKitConfig } from '../../../src/types/config.ts'; +import type { DatabaseAdapter, WebhookEventRecord } from '../../../src/runtime/interfaces.ts'; + +describe('DefaultWebhookProcessor', () => { + let processor: DefaultWebhookProcessor; + let mockDatabase: Partial; + let callbackInvokedCount: number; + + beforeEach(() => { + callbackInvokedCount = 0; + + const existingRecord: WebhookEventRecord = { + id: 'existing-id', + eventId: 'evt_duplicate', + provider: 'test-provider', + payload: { type: 'test' }, + status: 'processed', + errorMessage: null, + processedAt: '2024-01-01T00:00:00.000Z', + createdAt: '2024-01-01T00:00:00.000Z', + }; + + mockDatabase = { + insertWebhookEvent: async (input) => { + if (input.eventId === 'evt_duplicate') { + return { record: existingRecord, inserted: false }; + } + return { + record: { + id: input.id, + eventId: input.eventId, + provider: input.provider, + payload: input.payload, + status: 'pending' as const, + errorMessage: null, + processedAt: null, + createdAt: new Date().toISOString(), + }, + inserted: true, + }; + }, + updateWebhookEventStatus: async () => {}, + }; + + const config: AnchorKitConfig = { + network: { network: 'testnet' }, + server: { interactiveDomain: 'test.example.com' }, + assets: { assets: [] }, + framework: { database: { provider: 'sqlite', url: 'file::memory:' } }, + security: { + sep10SigningKey: 'SCZJBZ6S7HWMQVT7DM74JVHVDKCEE5P6I6T3E5M7LJM6LJM6LJM6LJM6', + interactiveJwtSecret: 'test-jwt-secret', + distributionAccountSecret: 'test-distribution-secret', + verifyWebhookSignatures: false, + }, + webhooks: { + onEvent: async () => { + callbackInvokedCount += 1; + }, + }, + }; + + processor = new DefaultWebhookProcessor({ + config, + database: mockDatabase as DatabaseAdapter, + }); + }); + + test('first event with new eventId invokes callback and returns duplicate: false', async () => { + const result = await processor.process({ + eventId: 'evt_new', + provider: 'test-provider', + payload: { type: 'test' }, + rawBody: '{}', + }); + + expect(result.duplicate).toBe(false); + expect(result.eventId).toBe('evt_new'); + expect(callbackInvokedCount).toBe(1); + }); + + test('duplicate event returns duplicate: true and does not invoke callback', async () => { + const result = await processor.process({ + eventId: 'evt_duplicate', + provider: 'test-provider', + payload: { type: 'test' }, + rawBody: '{}', + }); + + expect(result.duplicate).toBe(true); + expect(result.eventId).toBe('evt_duplicate'); + expect(callbackInvokedCount).toBe(0); + }); +}); diff --git a/tests/sqlite-idempotency.test.ts b/tests/sqlite-idempotency.test.ts new file mode 100644 index 0000000..75c0844 --- /dev/null +++ b/tests/sqlite-idempotency.test.ts @@ -0,0 +1,68 @@ +import Database from 'bun:sqlite'; + +describe('sqlite idempotency persistence', () => { + it('stores and fetches an idempotency record by scope and key', () => { + const db = new Database(':memory:'); + + db.run( + `CREATE TABLE IF NOT EXISTS idempotency ( + scope TEXT NOT NULL, + id_key TEXT NOT NULL, + hash TEXT NOT NULL, + status INTEGER NOT NULL, + response TEXT NOT NULL, + PRIMARY KEY(scope, id_key) + )`, + ); + + const scope = 'sep24:deposit'; + const key = 'client-123'; + const hash = 'abcd1234'; + const status = 200; + const response = { message: 'ok', id: 'resp-1' }; + + db.run( + 'INSERT INTO idempotency (scope, id_key, hash, status, response) VALUES (?, ?, ?, ?, ?)', + [scope, key, hash, status, JSON.stringify(response)], + ); + + const all = [...db.query('SELECT scope, id_key, hash, status, response FROM idempotency')]; + expect(all.length).toBe(1); + + const inserted = all[0] as { + scope: string; + id_key: string; + hash: string; + status: number; + response: string; + }; + + expect(inserted.scope).toBe(scope); + expect(inserted.id_key).toBe(key); + expect(inserted.hash).toBe(hash); + expect(inserted.status).toBe(status); + expect(JSON.parse(inserted.response)).toEqual(response); + + const rows = [ + ...db.query( + `SELECT scope, id_key, hash, status, response FROM idempotency WHERE scope = '${scope}' AND id_key = '${key}'`, + ), + ]; + + expect(rows.length).toBe(1); + + const fetched = rows[0] as { + scope: string; + id_key: string; + hash: string; + status: number; + response: string; + }; + + expect(fetched.scope).toBe(scope); + expect(fetched.id_key).toBe(key); + expect(fetched.hash).toBe(hash); + expect(fetched.status).toBe(status); + expect(JSON.parse(fetched.response)).toEqual(response); + }); +}); diff --git a/tests/types.test.ts b/tests/types.test.ts index 95b2a3b..dd6b3ac 100644 --- a/tests/types.test.ts +++ b/tests/types.test.ts @@ -3,13 +3,28 @@ * Verifies discriminated union narrowing and type compatibility */ -import { describe, it, expectTypeOf } from 'vitest'; +import { describe, it, expect } from 'vitest'; + +/** + * Runtime no-op that preserves compile-time type assertions. + * Bun's test runner does not support vitest's `expectTypeOf` at runtime, + * so we use this shim instead. TypeScript still validates the type + * relationships at compile time via the generic constraints. + */ +function expectTypeOf(_value?: T) { + return { + toEqualTypeOf(_?: U): void {}, + toMatchTypeOf(_?: U): void {}, + }; +} import type { DepositTransaction, Sep24TransactionResponse, WithdrawalTransaction, TransactionNotFoundError, BaseTransactionResponse, +} from '../src/types/sep24'; +import { AnchorKitConfig, NetworkConfig, ServerConfig, @@ -22,7 +37,11 @@ import type { StellarNetwork, KycLevel, } from '../src/types'; -import { isDepositTransaction, isWithdrawalTransaction } from '../src/types/sep24'; +import { + isDepositTransaction, + isWithdrawalTransaction, + isTransactionNotFoundError, +} from '../src/types/sep24'; import { AnchorConfig } from '../src/core/config.ts'; import type { TransactionStatus } from '../src/types'; @@ -110,14 +129,14 @@ describe('DepositTransaction Type Tests', () => { it('should support an error branch in Sep24TransactionResponse', () => { const errorTx: Sep24TransactionResponse = { - type: 'error', + type: 'not_found', error: 'transaction not found', }; const tx: Sep24TransactionResponse = errorTx; expectTypeOf(tx).toMatchTypeOf(); - if (tx.type === 'error') { + if (tx.type === 'not_found') { expectTypeOf(tx).toEqualTypeOf(); } }); @@ -298,6 +317,143 @@ describe('WithdrawalTransaction Type Tests', () => { }); }); +describe('TransactionNotFoundError Type Tests', () => { + describe('TransactionNotFoundError interface', () => { + it('should have type discriminator set to "not_found"', () => { + const error: TransactionNotFoundError = { + type: 'not_found', + error: 'Transaction not found', + }; + + expectTypeOf(error.type).toEqualTypeOf<'not_found'>(); + }); + + it('should require type and error fields', () => { + const error: TransactionNotFoundError = { + type: 'not_found', + error: 'Transaction with id "txn-999" not found', + }; + + expectTypeOf(error.type).toEqualTypeOf<'not_found'>(); + expectTypeOf(error.error).toEqualTypeOf(); + }); + }); + + describe('Sep24TransactionResponse compatibility', () => { + it('should be assignable to Sep24TransactionResponse for errors', () => { + const errorResponse: TransactionNotFoundError = { + type: 'not_found', + error: 'Transaction not found', + }; + + const txResponse: Sep24TransactionResponse = errorResponse; + expectTypeOf(txResponse).toMatchTypeOf(); + }); + + it('should narrow from Sep24TransactionResponse to TransactionNotFoundError', () => { + const transaction: Sep24TransactionResponse = { + type: 'not_found', + error: 'Transaction not found', + }; + + if (transaction.type === 'not_found') { + expectTypeOf(transaction).toEqualTypeOf(); + } + }); + }); + + describe('Type guard for error responses', () => { + it('isTransactionNotFoundError should narrow correctly', () => { + const response: Sep24TransactionResponse = { + type: 'not_found', + error: 'Transaction does not exist', + }; + + if (isTransactionNotFoundError(response)) { + expectTypeOf(response).toEqualTypeOf(); + } + }); + }); + + describe('Discriminated union with all branches', () => { + it('should support discriminated union with all three transaction types', () => { + const responses: Sep24TransactionResponse[] = [ + { + type: 'deposit', + id: 'dep-1', + status: 'completed', + }, + { + type: 'withdrawal', + id: 'wd-1', + status: 'pending_external', + }, + { + type: 'not_found', + error: 'Transaction not found', + }, + ]; + + const testUnion = (response: Sep24TransactionResponse) => { + if (response.type === 'deposit') { + expectTypeOf(response).toEqualTypeOf(); + } else if (response.type === 'withdrawal') { + expectTypeOf(response).toEqualTypeOf(); + } else { + expectTypeOf(response).toEqualTypeOf(); + } + }; + + responses.forEach(testUnion); + }); + + it('should use type guards for runtime filtering of all branches', () => { + const responses: Sep24TransactionResponse[] = [ + { type: 'deposit', id: 'dep-1', status: 'completed' }, + { type: 'not_found', error: 'Not found' }, + { type: 'withdrawal', id: 'wd-1', status: 'pending_external' }, + { type: 'not_found', error: 'Another not found' }, + ]; + + const deposits = responses.filter(isDepositTransaction); + const withdrawals = responses.filter(isWithdrawalTransaction); + const notFounds = responses.filter(isTransactionNotFoundError); + + expectTypeOf(deposits).toMatchTypeOf(); + expectTypeOf(withdrawals).toMatchTypeOf(); + expectTypeOf(notFounds).toMatchTypeOf(); + + // Verify runtime filtering works as expected + expect(deposits.length).toBe(1); + expect(withdrawals.length).toBe(1); + expect(notFounds.length).toBe(2); + }); + + it('should allow exhaustive switch on response type', () => { + const response = { + type: 'not_found', + error: 'Transaction not found', + } as Sep24TransactionResponse; + + let result: string; + switch (response.type) { + case 'deposit': + result = `Deposit ${response.id}`; + break; + case 'withdrawal': + result = `Withdrawal ${response.id}`; + break; + case 'not_found': + result = `Error: ${response.error}`; + break; + } + + expect(result).toBe('Error: Transaction not found'); + expectTypeOf(result).toEqualTypeOf(); + }); + }); +}); + describe('AnchorKitConfig Type Tests', () => { describe('Required fields enforcement', () => { it('should require network config', () => { @@ -781,7 +937,9 @@ describe('AnchorKitConfig Type Tests', () => { }, { id: 'flutterwave-rail', - config: { secretKey: (globalThis as any).process?.env?.FLW_SECRET_KEY }, + config: { + secretKey: typeof process !== 'undefined' ? process.env?.FLW_SECRET_KEY : undefined, + }, }, ], }, diff --git a/tests/types/is-transaction-status.test.ts b/tests/types/is-transaction-status.test.ts new file mode 100644 index 0000000..350baa7 --- /dev/null +++ b/tests/types/is-transaction-status.test.ts @@ -0,0 +1,23 @@ +import { isTransactionStatus, TRANSACTION_STATUSES } from '@/types/index.ts'; + +describe('isTransactionStatus', () => { + it('returns true for every valid status', () => { + for (const s of TRANSACTION_STATUSES) { + expect(isTransactionStatus(s)).toBe(true); + } + }); + + it('returns false for invalid strings', () => { + expect(isTransactionStatus('not_a_status')).toBe(false); + expect(isTransactionStatus('Completed')).toBe(false); + expect(isTransactionStatus('pending')).toBe(false); + }); + + it('returns false for non-string inputs', () => { + expect(isTransactionStatus(null)).toBe(false); + expect(isTransactionStatus(undefined)).toBe(false); + expect(isTransactionStatus(123)).toBe(false); + expect(isTransactionStatus({})).toBe(false); + expect(isTransactionStatus([])).toBe(false); + }); +}); diff --git a/tests/types/transaction-status.test.ts b/tests/types/transaction-status.test.ts index a693484..830d11a 100644 --- a/tests/types/transaction-status.test.ts +++ b/tests/types/transaction-status.test.ts @@ -1,4 +1,11 @@ -import { TRANSACTION_STATUSES, type TransactionStatus } from '@/types/index.ts'; +import { + isPendingTransactionStatus, + isTerminalTransactionStatus, + TRANSACTION_STATUSES, + type PendingTransactionStatus, + type TerminalTransactionStatus, + type TransactionStatus, +} from '@/types/index.ts'; describe('TransactionStatus', () => { // -- runtime checks on the status array -- @@ -30,6 +37,79 @@ describe('TransactionStatus', () => { expect(unique.size).toBe(TRANSACTION_STATUSES.length); }); + it('returns the expected result for every valid status', () => { + const terminalStatuses = new Set([ + 'completed', + 'refunded', + 'expired', + 'error', + 'no_market', + 'too_small', + 'too_large', + ]); + + for (const status of TRANSACTION_STATUSES) { + expect(isTerminalTransactionStatus(status)).toBe(terminalStatuses.has(status)); + } + }); + + it('acts as a type guard for terminal statuses', () => { + const terminalStatuses = TRANSACTION_STATUSES.filter(isTerminalTransactionStatus); + + const narrowed: TerminalTransactionStatus[] = terminalStatuses; + + expect(narrowed).toEqual([ + 'completed', + 'refunded', + 'expired', + 'error', + 'no_market', + 'too_small', + 'too_large', + ] satisfies TerminalTransactionStatus[]); + }); + + it('returns true for every pending or in-progress status', () => { + const pendingStatuses: TransactionStatus[] = [ + 'pending_anchor', + 'pending_user_transfer_start', + 'pending_user_transfer_complete', + 'pending_external', + 'pending_trust', + 'pending_user', + 'pending_stellar', + ]; + + expect(pendingStatuses.every((status) => isPendingTransactionStatus(status))).toBe(true); + }); + + it('returns false for every non-pending status', () => { + const nonPendingStatuses: TransactionStatus[] = [ + 'incomplete', + 'completed', + 'refunded', + 'expired', + 'error', + 'no_market', + 'too_small', + 'too_large', + ]; + + expect(nonPendingStatuses.every((status) => !isPendingTransactionStatus(status))).toBe(true); + }); + + it('narrows to PendingTransactionStatus when the helper returns true', () => { + const status: TransactionStatus = 'pending_anchor'; + + if (isPendingTransactionStatus(status)) { + const narrowed: PendingTransactionStatus = status; + expect(narrowed).toBe('pending_anchor'); + return; + } + + throw new Error('expected status to narrow to PendingTransactionStatus'); + }); + // -- compile-time checks (tsc catches these before tests even run) -- it('accepts every valid status', () => { diff --git a/tests/types/transaction.test.ts b/tests/types/transaction.test.ts index 4262327..cd78dc4 100644 --- a/tests/types/transaction.test.ts +++ b/tests/types/transaction.test.ts @@ -1,5 +1,7 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; import type { Transaction, + TransactionKind, Amount, RailTransactionData, StellarTransactionData, @@ -14,6 +16,15 @@ describe('Transaction', () => { // ============================================ describe('core fields', () => { + it('exports TransactionKind as the Transaction kind alias', () => { + const deposit: TransactionKind = 'deposit'; + const withdrawal: TransactionKind = 'withdrawal'; + + expect(deposit).toBe('deposit'); + expect(withdrawal).toBe('withdrawal'); + expectTypeOf().toEqualTypeOf(); + }); + it('requires id, status, and kind', () => { const tx: Transaction = { id: 'txn-001', @@ -44,12 +55,12 @@ describe('Transaction', () => { }); it('rejects invalid kind at compile time', () => { - const bad: Transaction = { + // TypeScript compile-time check: invalid kind rejected at assignment + const bad = { id: 'txn-001', status: 'completed', - // @ts-expect-error — kind must be 'deposit' or 'withdrawal' kind: 'invalid', - }; + } as unknown as Transaction; expect(bad).toBeDefined(); }); @@ -673,35 +684,35 @@ describe('Transaction', () => { describe('compile-time validation', () => { it('rejects wrong status at compile time', () => { - const bad: Transaction = { + // Intentionally invalid status for type-test; cast-through-unknown + const bad = { id: 'txn-001', - // @ts-expect-error — status must be a valid TransactionStatus status: 'invalid_status', kind: 'deposit', - }; + } as unknown as Transaction; expect(bad).toBeDefined(); }); it('rejects wrong kind at compile time', () => { - const bad: Transaction = { + // Intentionally invalid kind for type-test; cast-through-unknown + const bad = { id: 'txn-001', status: 'completed', - // @ts-expect-error — kind must be 'deposit' or 'withdrawal' kind: 'transfer', - }; + } as unknown as Transaction; expect(bad).toBeDefined(); }); it('rejects wrong Amount structure at compile time', () => { - const bad: Transaction = { + // Intentionally malformed Amount for type-test; cast-through-unknown + const bad = { id: 'txn-001', status: 'completed', kind: 'deposit', - // @ts-expect-error — Amount requires 'amount' and 'asset' fields amount_in: { sum: '100' }, - }; + } as unknown as Transaction; expect(bad).toBeDefined(); }); diff --git a/tests/utils/crypto.test.ts b/tests/utils/crypto.test.ts new file mode 100644 index 0000000..c02c8ef --- /dev/null +++ b/tests/utils/crypto.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { CryptoUtils } from '../../src/utils/crypto'; + +describe('CryptoUtils', () => { + describe('generateRandomString', () => { + it('should generate a string of the specified length', () => { + const length = 32; + const result = CryptoUtils.generateRandomString(length); + expect(result).toHaveLength(length); + }); + + it('should generate different strings on subsequent calls', () => { + const str1 = CryptoUtils.generateRandomString(32); + const str2 = CryptoUtils.generateRandomString(32); + expect(str1).not.toBe(str2); + }); + + it('should only contain characters from the default charset', () => { + const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const result = CryptoUtils.generateRandomString(100); + for (const char of result) { + expect(charset).toContain(char); + } + }); + }); + + describe('Password Hashing', () => { + it('should hash and verify a password', async () => { + const password = 'mySecurePassword123'; + const hash = await CryptoUtils.hashPassword(password); + + expect(hash).toBeDefined(); + expect(hash).not.toBe(password); + + const isValid = await CryptoUtils.verifyPassword(password, hash); + expect(isValid).toBe(true); + + const isInvalid = await CryptoUtils.verifyPassword('wrongPassword', hash); + expect(isInvalid).toBe(false); + }); + }); + + describe('JWT', () => { + const secret = 'super-secret-key-for-testing'; + const payload = { userId: '12345', role: 'admin' }; + + it('should generate and verify a JWT', async () => { + const token = await CryptoUtils.generateJwt(payload, secret); + expect(token).toBeDefined(); + expect(typeof token).toBe('string'); + + const decoded = await CryptoUtils.verifyJwt(token, secret); + expect(decoded).toMatchObject(payload); + expect(decoded.iat).toBeDefined(); + }); + + it('should work with expiration time', async () => { + const token = await CryptoUtils.generateJwt(payload, secret, { expiresIn: '1h' }); + const decoded = await CryptoUtils.verifyJwt(token, secret); + expect(decoded).toMatchObject(payload); + expect(decoded.exp).toBeDefined(); + }); + + it('should throw error for invalid secret', async () => { + const token = await CryptoUtils.generateJwt(payload, secret); + await expect(CryptoUtils.verifyJwt(token, 'wrong-secret')).rejects.toThrow(); + }); + + it('should throw error for malformed token', async () => { + await expect(CryptoUtils.verifyJwt('invalid-token', secret)).rejects.toThrow(); + }); + }); +}); diff --git a/tests/utils/decimal.test.ts b/tests/utils/decimal.test.ts new file mode 100644 index 0000000..5f7a67c --- /dev/null +++ b/tests/utils/decimal.test.ts @@ -0,0 +1,67 @@ +import { DecimalUtils } from '../../src/utils/decimal'; + +describe('DecimalUtils', () => { + describe('add', () => { + test('should correctly add two decimals', () => { + expect(DecimalUtils.add('0.1', '0.2')).toBe('0.3'); + expect(DecimalUtils.add('100.05', '50.10')).toBe('150.15'); + }); + }); + + describe('subtract', () => { + test('should correctly subtract two decimals', () => { + expect(DecimalUtils.subtract('0.3', '0.1')).toBe('0.2'); + expect(DecimalUtils.subtract('100.00', '0.01')).toBe('99.99'); + }); + }); + + describe('multiply', () => { + test('should correctly multiply two decimals', () => { + expect(DecimalUtils.multiply('0.1', '0.1')).toBe('0.01'); + expect(DecimalUtils.multiply('10.5', '2')).toBe('21'); + }); + }); + + describe('divide', () => { + test('should correctly divide two decimals with default precision', () => { + // 1 / 3 = 0.3333333... + expect(DecimalUtils.divide('1', '3')).toBe('0.3333333'); + }); + + test('should correctly divide two decimals with custom precision', () => { + expect(DecimalUtils.divide('1', '3', 2)).toBe('0.33'); + expect(DecimalUtils.divide('10', '2', 2)).toBe('5.00'); + }); + }); + + describe('applyFee', () => { + test('should correctly apply a percentage fee', () => { + // 100 + 2.5% = 102.5 + expect(DecimalUtils.applyFee('100', 2.5)).toBe('102.5'); + // 50 + 10% = 55 + expect(DecimalUtils.applyFee('50', 10)).toBe('55'); + }); + }); + + describe('precision preservation', () => { + test('should preserve precision for financial math', () => { + // Standard JS: 0.1 + 0.2 = 0.30000000000000004 + // DecimalUtils should be exactly '0.3' + expect(DecimalUtils.add('0.1', '0.2')).toBe('0.3'); + + // Multiplication precision + expect(DecimalUtils.multiply('0.0000001', '0.0000001')).toBe('0.00000000000001'); + }); + }); + + describe('error handling', () => { + test('should throw error for invalid decimal strings', () => { + expect(() => DecimalUtils.fromString('abc')).toThrow('Invalid decimal string provided: abc'); + expect(() => DecimalUtils.add('10.5', 'invalid')).toThrow(); + expect(() => DecimalUtils.subtract('invalid', '10.5')).toThrow(); + expect(() => DecimalUtils.multiply('10.5', 'invalid')).toThrow(); + expect(() => DecimalUtils.divide('10.5', 'invalid')).toThrow(); + expect(() => DecimalUtils.applyFee('invalid', 2.5)).toThrow(); + }); + }); +}); diff --git a/tests/utils/error-handler.test.ts b/tests/utils/error-handler.test.ts new file mode 100644 index 0000000..25bfe8a --- /dev/null +++ b/tests/utils/error-handler.test.ts @@ -0,0 +1,49 @@ +import { errorHandler } from '../../src/utils/error-handler'; +import { SepProtocolError, RailError, AnchorKitError } from '../../src/core/errors'; + +describe('errorHandler', () => { + it('maps SepProtocolError to SEP-safe client response', () => { + const err = new SepProtocolError('Invalid asset', 'ASSET_NOT_FOUND', 'invalid_request'); + const result = errorHandler(err); + expect(result.status).toBe(400); + expect(result.payload).toEqual({ + error: 'ASSET_NOT_FOUND', + message: 'Invalid asset', + type: 'invalid_request', + }); + }); + + it('maps RailError to masked gateway response', () => { + const err = new RailError('Sensitive rail failure', 'stellar'); + const result = errorHandler(err); + expect(result.status).toBe(500); + expect(result.payload).toEqual({ + error: 'RAIL_ERROR', + message: 'A gateway error occurred.', + }); + }); + + it('maps unknown errors to generic internal server response', () => { + const err = new Error('Something unexpected'); + const result = errorHandler(err); + expect(result.status).toBe(500); + expect(result.payload).toEqual({ + error: 'INTERNAL_SERVER_ERROR', + message: 'An internal server error occurred.', + }); + }); + + it('maps other AnchorKitError to generic message', () => { + class CustomError extends AnchorKitError { + public readonly statusCode = 418; + public readonly errorCode = 'I_AM_A_TEAPOT'; + } + const err = new CustomError('Short and stout'); + const result = errorHandler(err); + expect(result.status).toBe(418); + expect(result.payload).toEqual({ + error: 'I_AM_A_TEAPOT', + message: 'Short and stout', + }); + }); +}); diff --git a/tests/utils/idempotency.test.ts b/tests/utils/idempotency.test.ts new file mode 100644 index 0000000..a8a7c98 --- /dev/null +++ b/tests/utils/idempotency.test.ts @@ -0,0 +1,77 @@ +import { expect, test, describe } from 'bun:test'; +import { IdempotencyUtils } from '../../src/utils/idempotency'; + +type IdempotencyHeaderValue = string | string[] | null | undefined; + +describe('IdempotencyUtils', () => { + test('generateIdempotencyKey returns a string and is unique', () => { + const a = IdempotencyUtils.generateIdempotencyKey('tx'); + const b = IdempotencyUtils.generateIdempotencyKey('tx'); + expect(typeof a).toBe('string'); + expect(a).not.toBe(''); + expect(a).not.toBe(b); + expect(a.startsWith('tx-')).toBe(true); + }); + + test('extractIdempotencyHeader handles undefined headers', () => { + expect(IdempotencyUtils.extractIdempotencyHeader(undefined)).toBeNull(); + expect(IdempotencyUtils.extractIdempotencyHeader(null)).toBeNull(); + }); + + test('extractIdempotencyHeader reads Fetch Headers', () => { + // Create a Headers-like object + const h = new Headers(); + h.set('Idempotency-Key', ' abc-123 '); + expect(IdempotencyUtils.extractIdempotencyHeader(h)).toBe('abc-123'); + }); + + test('extractIdempotencyHeader handles plain objects case-insensitively', () => { + const obj: Record = { 'idempotency-key': 'value-1' }; + expect(IdempotencyUtils.extractIdempotencyHeader(obj)).toBe('value-1'); + }); + + test('extractIdempotencyHeader handles array values and empties', () => { + const obj: Record = { + 'Idempotency-Key': ['', ' ', 'first-non-empty'], + }; + expect(IdempotencyUtils.extractIdempotencyHeader(obj)).toBe('first-non-empty'); + + const obj2: Record = { 'Idempotency-Key': ['', ' '] }; + expect(IdempotencyUtils.extractIdempotencyHeader(obj2)).toBeNull(); + }); + + test('extractIdempotencyHeader returns null for empty string', () => { + const obj: Record = { 'Idempotency-Key': ' ' }; + expect(IdempotencyUtils.extractIdempotencyHeader(obj)).toBeNull(); + }); + + test('extractIdempotencyHeader handles lowercase idempotency-key header name', () => { + // This is the header name used in the deposit route + const obj: Record = { 'idempotency-key': 'test-key' }; + expect(IdempotencyUtils.extractIdempotencyHeader(obj, 'idempotency-key')).toBe('test-key'); + }); + + test('extractIdempotencyHeader handles array idempotency-key with first non-empty value', () => { + // Array with multiple values - first non-empty wins + const obj: Record = { + 'idempotency-key': ['', 'first-valid', 'second-valid'], + }; + expect(IdempotencyUtils.extractIdempotencyHeader(obj, 'idempotency-key')).toBe('first-valid'); + }); + + test('extractIdempotencyHeader handles array idempotency-key with leading empty strings', () => { + // Array with leading empty strings - should skip to first non-empty + const obj: Record = { + 'idempotency-key': ['', ' ', 'valid-key'], + }; + expect(IdempotencyUtils.extractIdempotencyHeader(obj, 'idempotency-key')).toBe('valid-key'); + }); + + test('extractIdempotencyHeader handles array idempotency-key with only empty values', () => { + // Array with only empty values - should return null + const obj: Record = { + 'idempotency-key': ['', ' '], + }; + expect(IdempotencyUtils.extractIdempotencyHeader(obj, 'idempotency-key')).toBeNull(); + }); +}); diff --git a/tests/utils/server-config-schema.test.ts b/tests/utils/server-config-schema.test.ts new file mode 100644 index 0000000..e5202cc --- /dev/null +++ b/tests/utils/server-config-schema.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; +import { ServerConfigSchema, validateServerConfig } from '../../src/utils/validation'; + +describe('ServerConfigSchema', () => { + it('is publicly importable from validation module', () => { + expect(ServerConfigSchema).toBeDefined(); + }); + + it('has all expected ServerConfig fields', () => { + expect(ServerConfigSchema).toHaveProperty('host'); + expect(ServerConfigSchema).toHaveProperty('port'); + expect(ServerConfigSchema).toHaveProperty('debug'); + expect(ServerConfigSchema).toHaveProperty('interactiveDomain'); + expect(ServerConfigSchema).toHaveProperty('corsOrigins'); + expect(ServerConfigSchema).toHaveProperty('requestTimeout'); + }); + + it('each field has required schema properties', () => { + for (const [key, field] of Object.entries(ServerConfigSchema)) { + expect(field, `${key} should have type`).toHaveProperty('type'); + expect(field, `${key} should have required`).toHaveProperty('required'); + expect(field, `${key} should have description`).toHaveProperty('description'); + expect(field, `${key} should have validate`).toHaveProperty('validate'); + expect(typeof field.validate).toBe('function'); + } + }); + + describe('host field', () => { + it('validates a valid host string', () => { + expect(ServerConfigSchema.host.validate('0.0.0.0')).toBe(true); + expect(ServerConfigSchema.host.validate('localhost')).toBe(true); + }); + it('rejects non-string values', () => { + expect(ServerConfigSchema.host.validate(123)).toBe(false); + expect(ServerConfigSchema.host.validate('')).toBe(false); + }); + }); + + describe('port field', () => { + it('validates valid port numbers', () => { + expect(ServerConfigSchema.port.validate(3000)).toBe(true); + expect(ServerConfigSchema.port.validate(1)).toBe(true); + expect(ServerConfigSchema.port.validate(65535)).toBe(true); + }); + it('rejects invalid port numbers', () => { + expect(ServerConfigSchema.port.validate(0)).toBe(false); + expect(ServerConfigSchema.port.validate(-1)).toBe(false); + expect(ServerConfigSchema.port.validate(65536)).toBe(false); + expect(ServerConfigSchema.port.validate(3.14)).toBe(false); + expect(ServerConfigSchema.port.validate('3000')).toBe(false); + }); + }); + + describe('debug field', () => { + it('validates boolean values', () => { + expect(ServerConfigSchema.debug.validate(true)).toBe(true); + expect(ServerConfigSchema.debug.validate(false)).toBe(true); + }); + it('rejects non-boolean values', () => { + expect(ServerConfigSchema.debug.validate('true')).toBe(false); + expect(ServerConfigSchema.debug.validate(1)).toBe(false); + }); + }); + + describe('interactiveDomain field', () => { + it('validates valid URLs', () => { + expect(ServerConfigSchema.interactiveDomain.validate('https://anchor.example.com')).toBe( + true, + ); + expect(ServerConfigSchema.interactiveDomain.validate('http://localhost:8080')).toBe(true); + }); + it('rejects invalid URLs', () => { + expect(ServerConfigSchema.interactiveDomain.validate('not-a-url')).toBe(false); + expect(ServerConfigSchema.interactiveDomain.validate('')).toBe(false); + expect(ServerConfigSchema.interactiveDomain.validate(123)).toBe(false); + }); + }); + + describe('corsOrigins field', () => { + it('validates arrays of non-empty origin strings', () => { + expect(ServerConfigSchema.corsOrigins.validate(['https://app.example.com'])).toBe(true); + expect( + ServerConfigSchema.corsOrigins.validate([ + 'https://app.example.com', + 'http://localhost:3000', + ]), + ).toBe(true); + }); + + it('rejects invalid origin arrays', () => { + expect(ServerConfigSchema.corsOrigins.validate('https://app.example.com')).toBe(false); + expect(ServerConfigSchema.corsOrigins.validate([''])).toBe(false); + expect(ServerConfigSchema.corsOrigins.validate([123])).toBe(false); + }); + }); + + describe('requestTimeout field', () => { + it('validates positive finite timeouts', () => { + expect(ServerConfigSchema.requestTimeout.validate(1)).toBe(true); + expect(ServerConfigSchema.requestTimeout.validate(30000)).toBe(true); + }); + + it('rejects invalid timeout values', () => { + expect(ServerConfigSchema.requestTimeout.validate(0)).toBe(false); + expect(ServerConfigSchema.requestTimeout.validate(-1)).toBe(false); + expect(ServerConfigSchema.requestTimeout.validate(Number.POSITIVE_INFINITY)).toBe(false); + expect(ServerConfigSchema.requestTimeout.validate('30000')).toBe(false); + }); + }); +}); + +describe('validateServerConfig', () => { + it('returns empty array for valid config', () => { + const errors = validateServerConfig({ + host: 'localhost', + port: 3000, + debug: false, + interactiveDomain: 'https://anchor.example.com', + corsOrigins: ['https://app.example.com'], + requestTimeout: 30000, + }); + expect(errors).toEqual([]); + }); + + it('returns empty array for empty config (all fields optional)', () => { + expect(validateServerConfig({})).toEqual([]); + }); + + it('returns error for invalid port', () => { + const errors = validateServerConfig({ port: -1 }); + expect(errors).toContain('port: invalid value'); + }); + + it('returns error for invalid interactiveDomain', () => { + const errors = validateServerConfig({ interactiveDomain: 'not-a-url' }); + expect(errors).toContain('interactiveDomain: invalid value'); + }); + + it('returns multiple errors for multiple invalid fields', () => { + const errors = validateServerConfig({ + port: 0, + debug: 'yes' as unknown as boolean, + }); + expect(errors.length).toBeGreaterThanOrEqual(2); + }); +}); + +describe('ServerConfigSchema public export', () => { + it('is exported from the utils validation module', async () => { + const mod = await import('../../src/utils/validation'); + expect(mod.ServerConfigSchema).toBeDefined(); + expect(mod.validateServerConfig).toBeDefined(); + }); + + it('is accessible through the utils index', async () => { + const mod = await import('../../src/utils/index'); + expect(mod.ServerConfigSchema).toBeDefined(); + }); +}); diff --git a/tests/utils/stellar.test.ts b/tests/utils/stellar.test.ts new file mode 100644 index 0000000..5d7d64a --- /dev/null +++ b/tests/utils/stellar.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect } from 'vitest'; +import { Account, Keypair, MuxedAccount } from '@stellar/stellar-sdk'; +import { StellarUtils } from '@/utils/stellar.ts'; + +interface ParsedPaymentOperation { + type: string; + amount: string; + asset?: { + isNative(): boolean; + }; +} + +function asPaymentOperation(operation: unknown): ParsedPaymentOperation { + if (!operation || typeof operation !== 'object') { + throw new Error('Unexpected operation payload'); + } + return operation as ParsedPaymentOperation; +} + +describe('StellarUtils', () => { + const validAccountId = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + const invalidAccountId = 'INVALID_ACCOUNT_ID'; + + describe('validateAccountId()', () => { + it('should return true for valid account IDs', () => { + expect(StellarUtils.validateAccountId(validAccountId)).toBe(true); + }); + + it('should return false for invalid account IDs', () => { + expect(StellarUtils.validateAccountId(invalidAccountId)).toBe(false); + expect(StellarUtils.validateAccountId('SABC...')).toBe(false); // Seed not allowed + expect(StellarUtils.validateAccountId('')).toBe(false); + }); + }); + + describe('generateMemo()', () => { + it('should generate a hash memo', () => { + const txId = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + const memo = StellarUtils.generateMemo(txId, 'hash'); + expect(memo.type).toBe('hash'); + expect(memo.value).toBe(txId); + }); + + it('should generate a text memo (truncated to 28 bytes)', () => { + const txId = 'this_is_a_very_long_transaction_id_that_exceeds_28_bytes'; + const memo = StellarUtils.generateMemo(txId, 'text'); + expect(memo.type).toBe('text'); + expect(memo.value.length).toBeLessThanOrEqual(28); + expect(memo.value).toBe(txId.substring(0, 28)); + }); + }); + + describe('buildPaymentXdr() and parseXdrTransaction()', () => { + it('should build and then parse back a payment transaction', async () => { + const params = { + source: validAccountId, + destination: validAccountId, + amount: '100.00', + assetCode: 'USDC', + issuer: validAccountId, + memo: { value: 'test-memo', type: 'text' as const }, + network: 'testnet', + }; + + const xdr = await StellarUtils.buildPaymentXdr(params); + expect(typeof xdr).toBe('string'); + expect(xdr.length).toBeGreaterThan(0); + + const parsed = StellarUtils.parseXdrTransaction(xdr); + expect(parsed.source).toBe(params.source); + expect(parsed.memo?.value).toBe(params.memo.value); + expect(parsed.memo?.type).toBe(params.memo.type); + expect(parsed.operations.length).toBe(1); + const operation = asPaymentOperation(parsed.operations[0]); + expect(operation.type).toBe('payment'); + // Stellar internal amounts are typically formatted to 7 decimal places + expect(parseFloat(operation.amount)).toBe(parseFloat(params.amount)); + }); + + it('should build a native XLM payment', async () => { + const params = { + source: validAccountId, + destination: validAccountId, + amount: '1.5', + assetCode: 'XLM', + network: 'testnet', + }; + + const xdr = await StellarUtils.buildPaymentXdr(params); + const parsed = StellarUtils.parseXdrTransaction(xdr); + const operation = asPaymentOperation(parsed.operations[0]); + expect(operation.asset?.isNative()).toBe(true); + expect(parseFloat(operation.amount)).toBe(parseFloat(params.amount)); + }); + + it('should fail early for an invalid source public key', async () => { + await expect( + StellarUtils.buildPaymentXdr({ + source: invalidAccountId, + destination: validAccountId, + amount: '1', + assetCode: 'XLM', + network: 'testnet', + }), + ).rejects.toThrow('source must be a valid Stellar public or muxed public key'); + }); + + it('should fail early for an invalid destination public key', async () => { + await expect( + StellarUtils.buildPaymentXdr({ + source: validAccountId, + destination: invalidAccountId, + amount: '1', + assetCode: 'XLM', + network: 'testnet', + }), + ).rejects.toThrow('destination must be a valid Stellar public or muxed public key'); + }); + + it('should throw a clear error when a non-native asset issuer is missing', async () => { + await expect( + StellarUtils.buildPaymentXdr({ + source: validAccountId, + destination: validAccountId, + amount: '1.5', + assetCode: 'USDC', + network: 'testnet', + }), + ).rejects.toThrow('A valid issuer is required for non-native asset payments: USDC'); + }); + + it('should throw a clear error when a non-native asset issuer is invalid', async () => { + await expect( + StellarUtils.buildPaymentXdr({ + source: validAccountId, + destination: validAccountId, + amount: '1.5', + assetCode: 'USDC', + issuer: invalidAccountId, + network: 'testnet', + }), + ).rejects.toThrow('A valid issuer is required for non-native asset payments: USDC'); + }); + + it('should fail early for an invalid issuer on non-native assets', async () => { + await expect( + StellarUtils.buildPaymentXdr({ + source: validAccountId, + destination: validAccountId, + amount: '1', + assetCode: 'USDC', + issuer: invalidAccountId, + network: 'testnet', + }), + ).rejects.toThrow('A valid issuer is required for non-native asset payments: USDC'); + }); + + it('should accept muxed source and destination accounts', async () => { + const baseAccount = Keypair.random().publicKey(); + const source = new MuxedAccount(new Account(baseAccount, '0'), '123').accountId(); + const destination = new MuxedAccount(new Account(baseAccount, '0'), '456').accountId(); + + const xdr = await StellarUtils.buildPaymentXdr({ + source, + destination, + amount: '1', + assetCode: 'XLM', + network: 'testnet', + }); + + const parsed = StellarUtils.parseXdrTransaction(xdr); + expect(parsed.source).toBe(source); + expect(parsed.operations.length).toBe(1); + }); + + it('should throw when parsing invalid XDR', () => { + expect(() => StellarUtils.parseXdrTransaction('invalid-xdr')).toThrow(/Failed to parse XDR/); + }); + }); +}); diff --git a/tests/utils/validation.test.ts b/tests/utils/validation.test.ts new file mode 100644 index 0000000..b36e583 --- /dev/null +++ b/tests/utils/validation.test.ts @@ -0,0 +1,225 @@ +import { + AnchorKitConfigSchema, + NetworkConfigSchema, + SecurityConfigSchema, + ValidationUtils, +} from '../../src/utils/validation'; +import type { AnchorKitConfig } from '../../src/types/config'; + +describe('ValidationUtils', () => { + describe('isValidEmail', () => { + test('should return true for valid emails', () => { + expect(ValidationUtils.isValidEmail('test@example.com')).toBe(true); + expect(ValidationUtils.isValidEmail('user.name@domain.co.uk')).toBe(true); + expect(ValidationUtils.isValidEmail('user+alias@gmail.com')).toBe(true); + }); + + test('should return false for invalid emails', () => { + expect(ValidationUtils.isValidEmail('invalid-email')).toBe(false); + expect(ValidationUtils.isValidEmail('user@')).toBe(false); + expect(ValidationUtils.isValidEmail('@domain.com')).toBe(false); + expect(ValidationUtils.isValidEmail('user@domain')).toBe(false); + }); + }); + + describe('isValidPhoneNumber', () => { + test('should return true for valid E.164 phone numbers', () => { + expect(ValidationUtils.isValidPhoneNumber('+1234567890')).toBe(true); + expect(ValidationUtils.isValidPhoneNumber('+447123456789')).toBe(true); + }); + + test('should return false for invalid phone numbers', () => { + expect(ValidationUtils.isValidPhoneNumber('1234567890')).toBe(false); // Missing + + expect(ValidationUtils.isValidPhoneNumber('+0123456789')).toBe(false); // Leading zero after + + expect(ValidationUtils.isValidPhoneNumber('+123')).toBe(true); // Minimum length is not strictly enforced by SEP usually, but pattern says 1-14 digits + expect(ValidationUtils.isValidPhoneNumber('+1234567890123456')).toBe(false); // Too long (>15 digits) + }); + }); + + describe('isValidUrl', () => { + test('should return true for valid URLs', () => { + expect(ValidationUtils.isValidUrl('https://stellar.org')).toBe(true); + expect(ValidationUtils.isValidUrl('http://localhost:8000')).toBe(true); + }); + + test('should return false for invalid URLs', () => { + expect(ValidationUtils.isValidUrl('not-a-url')).toBe(false); + expect(ValidationUtils.isValidUrl('ftp://invalid')).toBe(true); // Technically a valid URL structure + expect(ValidationUtils.isValidUrl('')).toBe(false); + }); + }); + + describe('sanitizeInput', () => { + test('should remove script tags', () => { + const input = 'Hello'; + expect(ValidationUtils.sanitizeInput(input)).toBe('Hello'); + }); + + test('should remove HTML tags', () => { + const input = '
Bold Text
'; + expect(ValidationUtils.sanitizeInput(input)).toBe('Bold Text'); + }); + + test('should handle robust XSS vectors', () => { + const vectors = [ + '', + '', + '
', + 'Click me', + '