diff --git a/.env.example b/.env.example index 39196578..81db9402 100644 --- a/.env.example +++ b/.env.example @@ -24,5 +24,11 @@ LOG_LEVEL=info # Database DB_PATH=scout-off.db +# PostgreSQL +DATABASE_URL=postgresql://user:password@localhost:5432/scoutoff +DATABASE_SSL=false +DB_POOL_MIN=2 +DB_POOL_MAX=10 + # Feature flags STELLAR_HEALTH_CHECK_ENABLED=true diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d9028680..f9f0ed2e 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,17 +1,17 @@ module.exports = { parser: '@typescript-eslint/parser', parserOptions: { - project: './tsconfig.json', + project: './tsconfig.eslint.json', tsconfigRootDir: __dirname, ecmaVersion: 2020, - sourceType: 'module' + sourceType: 'module', }, plugins: ['@typescript-eslint'], - extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], env: { node: true, jest: true, - es2021: true + es2021: true, }, - rules: {} + rules: {}, }; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27599400..6f1566b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,9 @@ jobs: - name: Validate env example run: node scripts/validate-env.js + - name: Check formatting + run: npm run format:check + - name: Lint run: npm run lint diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..a3b16ffa --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.db +*.db-journal +package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..c8481ad2 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "endOfLine": "lf" +} diff --git a/BACKEND_API_DOCS.md b/BACKEND_API_DOCS.md index 00c042fc..e80cc6b5 100644 --- a/BACKEND_API_DOCS.md +++ b/BACKEND_API_DOCS.md @@ -25,6 +25,7 @@ Tokens are issued after a successful SEP-10 Stellar wallet challenge/response fl Liveness check. No auth required. **Response `200`** + ```json { "status": "ok", @@ -44,11 +45,12 @@ Returns a SEP-10 challenge XDR for the given Stellar account. No auth required. **Query params** -| Param | Type | Required | Description | -|-----------|--------|----------|--------------------------| -| `account` | string | ✅ | Stellar public key (G…) | +| Param | Type | Required | Description | +| --------- | ------ | -------- | ----------------------- | +| `account` | string | ✅ | Stellar public key (G…) | **Response `200`** + ```json { "challenge": "", @@ -63,6 +65,7 @@ Returns a SEP-10 challenge XDR for the given Stellar account. No auth required. Submit a signed SEP-10 XDR to receive a JWT. No auth required. **Request body** + ```json { "signedXdr": "", @@ -71,6 +74,7 @@ Submit a signed SEP-10 XDR to receive a JWT. No auth required. ``` **Response `200`** + ```json { "token": "", @@ -88,6 +92,7 @@ Submit a signed SEP-10 XDR to receive a JWT. No auth required. Pin player metadata to IPFS and return the content ID. No auth required. **Request body** + ```json { "wallet": "GABC...XYZ", @@ -104,6 +109,7 @@ Pin player metadata to IPFS and return the content ID. No auth required. ``` **Response `201`** + ```json { "success": true, @@ -122,15 +128,16 @@ Filter players by region, position, and minimum verified tier. No auth required. **Query params** -| Param | Type | Required | Description | -|------------|---------|----------|--------------------------------------| -| `region` | string | ❌ | Filter by region | -| `position` | string | ❌ | Filter by position | -| `minTier` | integer | ❌ | Minimum progress level (0–3) | -| `page` | integer | ❌ | Page number (default: 1) | +| Param | Type | Required | Description | +| ---------- | ------- | -------- | ---------------------------------------- | +| `region` | string | ❌ | Filter by region | +| `position` | string | ❌ | Filter by position | +| `minTier` | integer | ❌ | Minimum progress level (0–3) | +| `page` | integer | ❌ | Page number (default: 1) | | `pageSize` | integer | ❌ | Results per page (default: 20, max: 100) | **Response `200`** + ```json { "success": true, @@ -150,6 +157,7 @@ Filter players by region, position, and minimum verified tier. No auth required. ``` **Error `400`** — invalid `minTier` + ```json { "success": false, @@ -164,6 +172,7 @@ Filter players by region, position, and minimum verified tier. No auth required. Retrieve a single player profile. No auth required. **Response `200`** + ```json { "success": true, @@ -180,6 +189,7 @@ Retrieve a single player profile. No auth required. ``` **Error `404`** + ```json { "success": false, "error": "Player not found" } ``` @@ -191,6 +201,7 @@ Retrieve a single player profile. No auth required. Tamper-proof milestone history for a player. No auth required. **Response `200`** + ```json { "success": true, @@ -218,6 +229,7 @@ Tamper-proof milestone history for a player. No auth required. Check active subscription status for a scout. **Requires Bearer auth.** **Response `200`** + ```json { "success": true, @@ -237,12 +249,11 @@ Check active subscription status for a scout. **Requires Bearer auth.** List players unlocked by a scout. **Requires Bearer auth.** **Response `200`** + ```json { "success": true, - "data": [ - { "playerId": "abc123", "unlockedAt": 1700000000 } - ] + "data": [{ "playerId": "abc123", "unlockedAt": 1700000000 }] } ``` @@ -257,6 +268,7 @@ List players unlocked by a scout. **Requires Bearer auth.** Pin milestone evidence to IPFS and return the CID. **Requires Bearer auth (validator role).** **Request body** + ```json { "playerId": "abc123", @@ -269,6 +281,7 @@ Pin milestone evidence to IPFS and return the CID. **Requires Bearer auth (valid ``` **Response `201`** + ```json { "success": true, @@ -286,6 +299,7 @@ Pin milestone evidence to IPFS and return the CID. **Requires Bearer auth (valid List pending milestone approvals. **Requires Bearer auth (validator role).** **Response `200`** + ```json { "success": true, @@ -312,6 +326,7 @@ List pending milestone approvals. **Requires Bearer auth (validator role).** Platform-wide counts. **Requires Bearer auth (admin role).** **Response `200`** + ```json { "success": true, @@ -331,6 +346,7 @@ Platform-wide counts. **Requires Bearer auth (admin role).** All indexed contract events. **Requires Bearer auth.** **Response `200`** + ```json { "success": true, @@ -352,6 +368,7 @@ All indexed contract events. **Requires Bearer auth.** Fee withdrawal history. **Requires Bearer auth.** **Response `200`** + ```json { "success": true, @@ -372,11 +389,11 @@ Fee withdrawal history. **Requires Bearer auth.** The following routes currently return data sourced entirely from indexed on-chain events and have no corresponding write/mutation endpoint in the backend: -| Route | Reason | -|-------|--------| -| `GET /api/scouts/:wallet/subscription` | Subscription state managed on-chain via `subscribe()`; backend is read-only | -| `GET /api/scouts/:wallet/contacts` | Contact unlocks managed on-chain via `pay_to_contact()`; backend is read-only | -| `GET /api/validators/milestones/pending` | Milestone approval is an on-chain transaction; backend only indexes events | +| Route | Reason | +| ---------------------------------------- | ----------------------------------------------------------------------------- | +| `GET /api/scouts/:wallet/subscription` | Subscription state managed on-chain via `subscribe()`; backend is read-only | +| `GET /api/scouts/:wallet/contacts` | Contact unlocks managed on-chain via `pay_to_contact()`; backend is read-only | +| `GET /api/validators/milestones/pending` | Milestone approval is an on-chain transaction; backend only indexes events | --- @@ -393,10 +410,10 @@ All error responses follow this shape: Common HTTP status codes: -| Code | Meaning | -|------|--------------------------------| -| 400 | Validation error | -| 401 | Missing or invalid auth token | -| 403 | Insufficient permissions | -| 404 | Resource not found | -| 500 | Internal server error | +| Code | Meaning | +| ---- | ----------------------------- | +| 400 | Validation error | +| 401 | Missing or invalid auth token | +| 403 | Insufficient permissions | +| 404 | Resource not found | +| 500 | Internal server error | diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index d660c52a..729913b8 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -4,19 +4,19 @@ Copy `.env.example` to `.env` and fill in all required values before starting the server. -| Variable | Required | Notes | -|---|---|---| -| `CONTRACT_ID` | ✅ | Deployed Soroban contract address | -| `JWT_SECRET` | ✅ | Min 32 chars; rotate on compromise | -| `HORIZON_URL` | ✅ | e.g. `https://horizon-testnet.stellar.org` | -| `SOROBAN_RPC_URL` | ✅ | e.g. `https://soroban-testnet.stellar.org` | -| `NETWORK` | ✅ | `testnet` or `mainnet` | -| `PINATA_API_KEY` / `PINATA_SECRET` | ✅ | IPFS upload credentials | -| `DB_PATH` | — | SQLite file path (default: `scout-off.db`) | -| `PORT` | — | API port (default: `4000`) | -| `LOG_LEVEL` | — | `debug` / `info` / `warn` / `error` | -| `STELLAR_HEALTH_CHECK_ENABLED` | — | Set `false` in staging to skip Stellar RPC check | -| `TRUSTED_PROXY_COUNT` | — | Number of trusted reverse proxies (default: `1`) | +| Variable | Required | Notes | +| ---------------------------------- | -------- | ------------------------------------------------ | +| `CONTRACT_ID` | ✅ | Deployed Soroban contract address | +| `JWT_SECRET` | ✅ | Min 32 chars; rotate on compromise | +| `HORIZON_URL` | ✅ | e.g. `https://horizon-testnet.stellar.org` | +| `SOROBAN_RPC_URL` | ✅ | e.g. `https://soroban-testnet.stellar.org` | +| `NETWORK` | ✅ | `testnet` or `mainnet` | +| `PINATA_API_KEY` / `PINATA_SECRET` | ✅ | IPFS upload credentials | +| `DB_PATH` | — | SQLite file path (default: `scout-off.db`) | +| `PORT` | — | API port (default: `4000`) | +| `LOG_LEVEL` | — | `debug` / `info` / `warn` / `error` | +| `STELLAR_HEALTH_CHECK_ENABLED` | — | Set `false` in staging to skip Stellar RPC check | +| `TRUSTED_PROXY_COUNT` | — | Number of trusted reverse proxies (default: `1`) | ## Build & Start @@ -52,15 +52,18 @@ Always back up the database file before running migrations in production. ## Health & Monitoring -| Endpoint | Purpose | -|---|---| +| Endpoint | Purpose | +| ------------- | ------------------------------------------- | | `GET /health` | Liveness check; includes Stellar RPC status | -| `GET /ready` | Readiness probe; checks IPFS connectivity | +| `GET /ready` | Readiness probe; checks IPFS connectivity | Configure your load balancer or orchestrator to poll `/health` every 30 seconds. Alert on consecutive failures (≥ 2) to catch Stellar RPC or IPFS outages early. +In the event of an outage, refer to the [Dependency Outages Runbook](docs/runbooks/dependency-outages.md) for mitigation and recovery procedures. + Recommended metrics to track: + - HTTP 5xx error rate - Event indexer lag (gap between latest on-chain event and last indexed event) - SQLite file size growth diff --git a/ISSUE_PUSH_SUMMARY.md b/ISSUE_PUSH_SUMMARY.md index 8cb1c89c..8c68c4ff 100644 --- a/ISSUE_PUSH_SUMMARY.md +++ b/ISSUE_PUSH_SUMMARY.md @@ -1,3 +1,3 @@ # Issue Push Summary -This repository has new GitHub issues created programmatically from the notebook. \ No newline at end of file +This repository has new GitHub issues created programmatically from the notebook. diff --git a/README.md b/README.md index 0a463a66..3051696c 100644 --- a/README.md +++ b/README.md @@ -97,25 +97,25 @@ graph TB Tiers are gated by real-world verification and enforced on-chain: -| Level | Name | Requirement | -|-------|-----------------------|--------------------------------------------------------------| -| 0 | Unverified | Player creates profile and uploads data | -| 1 | Verified Identity | KYC passed or academy confirms active club membership | -| 2 | Performance Milestones| Match footage or physical stats verified by approved third party | -| 3 | Elite Tier | Scout feedback or trial offer logged on-chain | +| Level | Name | Requirement | +| ----- | ---------------------- | ---------------------------------------------------------------- | +| 0 | Unverified | Player creates profile and uploads data | +| 1 | Verified Identity | KYC passed or academy confirms active club membership | +| 2 | Performance Milestones | Match footage or physical stats verified by approved third party | +| 3 | Elite Tier | Scout feedback or trial offer logged on-chain | Example: A validator submits "Scored 5 goals in Local Cup" → Soroban contract writes the milestone → player's progress bar updates → scouts see a tamper-proof history of when and how the player progressed. ## Tech Stack -| Layer | Technology | Purpose | -|------------------|-----------------------------------|-------------------------------------------------------------------------| -| Smart Contracts | Rust + Soroban (Stellar) | Player registration, progress verification, scout subscriptions, contact agreements | -| Frontend | Next.js / Flutter | Player upload dashboard, scout browse interface, validator approval panel | -| Backend | Node.js + Express | Event indexing, search caching, REST API for heavy queries | -| File Storage | IPFS / Arweave (via Pinata) | Highlight reels, photos, and documents; hashes stored on-chain | -| Auth | SEP-10 (Stellar) | Secure wallet-based login for players and scouts | -| Payments | XLM / Platform Token | Scout subscriptions, pay-to-contact micro-fees | +| Layer | Technology | Purpose | +| --------------- | --------------------------- | ----------------------------------------------------------------------------------- | +| Smart Contracts | Rust + Soroban (Stellar) | Player registration, progress verification, scout subscriptions, contact agreements | +| Frontend | Next.js / Flutter | Player upload dashboard, scout browse interface, validator approval panel | +| Backend | Node.js + Express | Event indexing, search caching, REST API for heavy queries | +| File Storage | IPFS / Arweave (via Pinata) | Highlight reels, photos, and documents; hashes stored on-chain | +| Auth | SEP-10 (Stellar) | Secure wallet-based login for players and scouts | +| Payments | XLM / Platform Token | Scout subscriptions, pay-to-contact micro-fees | ## Smart Contract Functions @@ -153,22 +153,22 @@ Example: A validator submits "Scored 5 goals in Local Cup" → Soroban contract ## Backend API Endpoints -| Method | Path | Auth | Description | -|--------|------|------|-------------| -| `GET` | `/health` | — | Liveness check | -| `GET` | `/auth/challenge?account=G...` | — | Get SEP-10 challenge XDR to sign | -| `POST` | `/auth/token` | — | Submit signed XDR, receive JWT | -| `POST` | `/api/players/register` | — | Pin metadata to IPFS, return CID | -| `GET` | `/api/players` | — | Filter players (`region`, `position`, `minTier`) | -| `GET` | `/api/players/:playerId` | — | Single player profile | -| `GET` | `/api/players/:playerId/milestones` | — | Milestone history | -| `GET` | `/api/scouts/:wallet/subscription` | Bearer | Subscription status | -| `GET` | `/api/scouts/:wallet/contacts` | Bearer | Unlocked contacts | -| `POST` | `/api/validators/milestone` | Bearer | Pin evidence, return CID | -| `GET` | `/api/validators/milestones/pending` | Bearer | Pending milestone approvals | -| `GET` | `/api/admin/stats` | Bearer (admin) | Platform counts: players, milestones, subscriptions, events | -| `GET` | `/api/admin/events` | Bearer | All indexed contract events | -| `GET` | `/api/admin/fees` | Bearer | Fee withdrawal history | +| Method | Path | Auth | Description | +| ------ | ------------------------------------ | -------------- | ----------------------------------------------------------- | +| `GET` | `/health` | — | Liveness check | +| `GET` | `/auth/challenge?account=G...` | — | Get SEP-10 challenge XDR to sign | +| `POST` | `/auth/token` | — | Submit signed XDR, receive JWT | +| `POST` | `/api/players/register` | — | Pin metadata to IPFS, return CID | +| `GET` | `/api/players` | — | Filter players (`region`, `position`, `minTier`) | +| `GET` | `/api/players/:playerId` | — | Single player profile | +| `GET` | `/api/players/:playerId/milestones` | — | Milestone history | +| `GET` | `/api/scouts/:wallet/subscription` | Bearer | Subscription status | +| `GET` | `/api/scouts/:wallet/contacts` | Bearer | Unlocked contacts | +| `POST` | `/api/validators/milestone` | Bearer | Pin evidence, return CID | +| `GET` | `/api/validators/milestones/pending` | Bearer | Pending milestone approvals | +| `GET` | `/api/admin/stats` | Bearer (admin) | Platform counts: players, milestones, subscriptions, events | +| `GET` | `/api/admin/events` | Bearer | All indexed contract events | +| `GET` | `/api/admin/fees` | Bearer | Fee withdrawal history | ## Player Progress Flow @@ -243,10 +243,10 @@ sequenceDiagram ### Valid Transitions -| From | To | Trigger | -|---------|---------|----------------------------------------------------------------| +| From | To | Trigger | +| ------- | ------- | ------------------------------------------------------------- | | Level 0 | Level 1 | Academy or KYC provider calls `approve_milestone` (identity) | -| Level 1 | Level 2 | Approved validator submits and approves performance milestone | +| Level 1 | Level 2 | Approved validator submits and approves performance milestone | | Level 2 | Level 3 | Scout calls `log_trial_offer` — offer recorded on-chain | ## Security Features @@ -308,15 +308,16 @@ npm run dev **Available npm scripts:** -| Script | Command | Description | -|--------|---------|-------------| -| `npm run dev` | `ts-node-dev --respawn --transpile-only src/index.ts` | Start with hot-reload for development | -| `npm run build` | `tsc` | Compile TypeScript to `dist/` | -| `npm start` | `node dist/index.js` | Run the compiled server (run `build` first) | -| `npm test` | `jest --runInBand` | Run the test suite | -| `npm run lint` | `eslint 'src/**/*.ts' 'tests/**/*.ts' --ext .ts` | Run TypeScript linting | +| Script | Command | Description | +| --------------- | ----------------------------------------------------- | ------------------------------------------- | +| `npm run dev` | `ts-node-dev --respawn --transpile-only src/index.ts` | Start with hot-reload for development | +| `npm run build` | `tsc` | Compile TypeScript to `dist/` | +| `npm start` | `node dist/index.js` | Run the compiled server (run `build` first) | +| `npm test` | `jest --runInBand` | Run the test suite | +| `npm run lint` | `eslint 'src/**/*.ts' 'tests/**/*.ts' --ext .ts` | Run TypeScript linting | On startup the server will: + - Open (or create) a SQLite database at `DB_PATH` (default: `scout-off.db`) - Begin polling Soroban for contract events every 5 seconds - Fail fast if `CONTRACT_ID` or `JWT_SECRET` are missing @@ -325,12 +326,12 @@ See [DEPLOYMENT.md](DEPLOYMENT.md) for complete deployment instructions. ## Health Endpoints -The backend exposes two health check endpoints for monitoring and orchestration probes. +The backend exposes two health check endpoints for monitoring and orchestration probes. For detailed instructions on handling external dependency outages (Stellar RPC or IPFS/Pinata), refer to the [Dependency Outages Runbook](docs/runbooks/dependency-outages.md). -| Method | Path | Auth | Description | -|--------|------|------|-------------| -| `GET` | `/health` | — | Liveness check — always returns `200 ok` with optional Stellar RPC status | -| `GET` | `/ready` | — | Readiness probe — returns `200` when all dependencies are reachable, `503` when degraded | +| Method | Path | Auth | Description | +| ------ | --------- | ---- | ---------------------------------------------------------------------------------------- | +| `GET` | `/health` | — | Liveness check — always returns `200 ok` with optional Stellar RPC status | +| `GET` | `/ready` | — | Readiness probe — returns `200` when all dependencies are reachable, `503` when degraded | ### GET /health @@ -341,6 +342,7 @@ Optionally includes a Stellar RPC connectivity check, controlled by the `STELLAR **Middleware module:** `src/services/stellar.ts` (`stellarHealth`) **Example response (healthy):** + ```json { "status": "ok", @@ -351,6 +353,7 @@ Optionally includes a Stellar RPC connectivity check, controlled by the `STELLAR ``` **Example response (Stellar disabled):** + ```json { "status": "ok", @@ -371,6 +374,7 @@ Currently checks: **IPFS (Pinata)** storage connectivity. **Middleware module:** `src/services/ipfs.ts` (`checkHealth`) **Example response (ready):** + ```json { "status": "ok", @@ -381,6 +385,7 @@ Currently checks: **IPFS (Pinata)** storage connectivity. ``` **Example response (degraded):** + ```json { "status": "degraded", @@ -394,10 +399,10 @@ Currently checks: **IPFS (Pinata)** storage connectivity. ### Dependencies -| Endpoint | Dependency | Stub / Module | -|----------|-----------|---------------| -| `/health` | Stellar RPC (`SOROBAN_RPC_URL`) | `src/services/stellar.ts` — `stellarHealth()` | -| `/ready` | IPFS / Pinata (`PINATA_API_KEY`) | `src/services/ipfs.ts` — `checkHealth()` | +| Endpoint | Dependency | Stub / Module | +| --------- | -------------------------------- | --------------------------------------------- | +| `/health` | Stellar RPC (`SOROBAN_RPC_URL`) | `src/services/stellar.ts` — `stellarHealth()` | +| `/ready` | IPFS / Pinata (`PINATA_API_KEY`) | `src/services/ipfs.ts` — `checkHealth()` | Both dependency checks are stubbed in tests — see `tests/routes/health.test.ts`. @@ -434,19 +439,19 @@ Both dependency checks are stubbed in tests — see `tests/routes/health.test.ts ### Key Environment Variables -| Variable | Description | -|---------------------------|-----------------------------------------------------| -| `CONTRACT_ID` | Deployed ScoutOff contract address (**required**) | -| `JWT_SECRET` | Secret used to sign SEP-10 JWT tokens (**required**)| -| `HORIZON_URL` | Stellar Horizon endpoint | -| `SOROBAN_RPC_URL` | Soroban RPC endpoint | -| `NETWORK` | `testnet` or `mainnet` | -| `PINATA_API_KEY` | Pinata API key for IPFS uploads | -| `PINATA_SECRET` | Pinata secret | -| `PLATFORM_FEE_BPS` | Platform fee in basis points (default: 500) | -| `PORT` | Backend API port (default: 4000) | -| `DB_PATH` | SQLite database file path (default: `scout-off.db`) | -| `LOG_LEVEL` | Log verbosity: `debug`, `info`, `warn`, `error` (default: `info`) | +| Variable | Description | +| ------------------------------ | ---------------------------------------------------------------------------------------------- | +| `CONTRACT_ID` | Deployed ScoutOff contract address (**required**) | +| `JWT_SECRET` | Secret used to sign SEP-10 JWT tokens (**required**) | +| `HORIZON_URL` | Stellar Horizon endpoint | +| `SOROBAN_RPC_URL` | Soroban RPC endpoint | +| `NETWORK` | `testnet` or `mainnet` | +| `PINATA_API_KEY` | Pinata API key for IPFS uploads | +| `PINATA_SECRET` | Pinata secret | +| `PLATFORM_FEE_BPS` | Platform fee in basis points (default: 500) | +| `PORT` | Backend API port (default: 4000) | +| `DB_PATH` | SQLite database file path (default: `scout-off.db`) | +| `LOG_LEVEL` | Log verbosity: `debug`, `info`, `warn`, `error` (default: `info`) | | `STELLAR_HEALTH_CHECK_ENABLED` | Include Stellar RPC in `/health` response (default: `true`; set `false` to disable in staging) | ## Testing @@ -460,6 +465,7 @@ npm run test ``` Contract test coverage includes: + - ✅ Player registration and metadata storage - ✅ Milestone submission and approval by validators - ✅ Progress tier increments and tamper-proof history @@ -500,25 +506,25 @@ Everything else (subscriptions, trial offer logging, fractionalized sponsorship) ## Error Codes -| Code | Error | Description | Resolution | -|------|---------------------|------------------------------------------|-------------------------------------------------| -| 1 | AlreadyInitialized | Contract already initialized | No action needed; contract is ready | -| 2 | NotInitialized | Contract not initialized | Admin must call `initialize` first | -| 3 | PlayerNotFound | Player ID does not exist | Verify player_id from registration transaction | -| 4 | InvalidValidator | Caller is not a registered validator | Admin must register the validator first | -| 5 | MilestoneNotFound | Milestone ID does not exist | Refresh milestone list | -| 6 | AlreadyVerified | Milestone already approved | No duplicate approvals needed | -| 7 | InsufficientFee | Payment below required contact fee | Check current fee via `get_contact_fee()` | -| 8 | NotSubscribed | Scout has no active subscription | Call `subscribe` before browsing premium data | -| 9 | Unauthorized | Caller is not authorized for this action | Confirm you are using the correct Stellar account | -| 10 | ContractPaused | Contract is paused | Wait for admin to unpause | -| 11 | Overflow | Arithmetic overflow in fee calculation | Use amounts within safe u128 range | +| Code | Error | Description | Resolution | +| ---- | ------------------ | ---------------------------------------- | ------------------------------------------------- | +| 1 | AlreadyInitialized | Contract already initialized | No action needed; contract is ready | +| 2 | NotInitialized | Contract not initialized | Admin must call `initialize` first | +| 3 | PlayerNotFound | Player ID does not exist | Verify player_id from registration transaction | +| 4 | InvalidValidator | Caller is not a registered validator | Admin must register the validator first | +| 5 | MilestoneNotFound | Milestone ID does not exist | Refresh milestone list | +| 6 | AlreadyVerified | Milestone already approved | No duplicate approvals needed | +| 7 | InsufficientFee | Payment below required contact fee | Check current fee via `get_contact_fee()` | +| 8 | NotSubscribed | Scout has no active subscription | Call `subscribe` before browsing premium data | +| 9 | Unauthorized | Caller is not authorized for this action | Confirm you are using the correct Stellar account | +| 10 | ContractPaused | Contract is paused | Wait for admin to unpause | +| 11 | Overflow | Arithmetic overflow in fee calculation | Use amounts within safe u128 range | ## Events -| Event | Emitted When | -|---------------------|-----------------------------------------------------------| -| `player_registered` | New player profile created on-chain | +| Event | Emitted When | +| --------------------- | ------------------------------------------------------- | +| `player_registered` | New player profile created on-chain | | `milestone_submitted` | Validator submits a new milestone for review | | `milestone_approved` | Validator approves milestone; progress tier incremented | | `scout_subscribed` | Scout purchases an active subscription | @@ -550,6 +556,7 @@ MIT Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. Quick checklist: + - All contract tests pass: `cargo test` - All backend tests pass: `npm run test` - New features include tests and updated documentation diff --git a/__mocks__/better-sqlite3.js b/__mocks__/better-sqlite3.js index b9153cca..51b36f56 100644 --- a/__mocks__/better-sqlite3.js +++ b/__mocks__/better-sqlite3.js @@ -17,10 +17,52 @@ class Statement { if (!this._db._events.find((e) => e.tx_hash === txHash)) { this._db._events.push({ type, ledger, tx_hash: txHash, payload }); } - } else if (sql.startsWith('INSERT INTO INDEXER_STATE') || sql.startsWith('INSERT OR REPLACE INTO INDEXER_STATE')) { + return { changes: 1, lastInsertRowid: 0 }; + } + + if ( + sql.startsWith('INSERT INTO INDEXER_STATE') || + sql.startsWith('INSERT OR REPLACE INTO INDEXER_STATE') + ) { const [key, value] = args; this._db._state.set(key, value); + return { changes: 1, lastInsertRowid: 0 }; + } + + if (sql.startsWith('INSERT INTO IDEMPOTENCY_KEYS')) { + const [key, expiresAt, requestHash, method, path, statusCode, responseBody, createdAt] = args; + this._db._idempotencyRows.set(key, { + key, + expires_at: expiresAt, + request_hash: requestHash, + method, + path, + status_code: statusCode, + response_body: responseBody, + created_at: createdAt, + }); + return { changes: 1, lastInsertRowid: 0 }; } + + if (sql.startsWith('DELETE FROM IDEMPOTENCY_KEYS')) { + const threshold = args[0]; + let deleted = 0; + for (const [key, row] of Array.from(this._db._idempotencyRows.entries())) { + if (sql.includes('CREATED_AT')) { + if (row.created_at < threshold) { + this._db._idempotencyRows.delete(key); + deleted += 1; + } + } else { + if (row.expires_at <= threshold) { + this._db._idempotencyRows.delete(key); + deleted += 1; + } + } + } + return { changes: deleted, lastInsertRowid: 0 }; + } + return { changes: 1, lastInsertRowid: 0 }; } @@ -31,6 +73,25 @@ class Statement { const value = this._db._state.get(key); return value !== undefined ? { value } : undefined; } + + if (sql.includes('FROM IDEMPOTENCY_KEYS')) { + const [key, now] = args; + const row = this._db._idempotencyRows.get(key); + if (row && row.expires_at > now) { + return { + key: row.key, + expiresAt: row.expires_at, + requestHash: row.request_hash, + method: row.method, + path: row.path, + statusCode: row.status_code, + responseBody: row.response_body, + createdAt: row.created_at, + }; + } + return undefined; + } + return undefined; } @@ -42,6 +103,13 @@ class Statement { } return [...this._db._events]; } + + if (sql.includes('FROM IDEMPOTENCY_KEYS')) { + return Array.from(this._db._idempotencyRows.values()).map((row) => ({ + key: row.key, + })); + } + return []; } } @@ -50,6 +118,7 @@ class Database { constructor(_path) { this._events = []; this._state = new Map(); + this._idempotencyRows = new Map(); } exec(_sql) { diff --git a/__mocks__/pg.js b/__mocks__/pg.js new file mode 100644 index 00000000..c76f5b7d --- /dev/null +++ b/__mocks__/pg.js @@ -0,0 +1,37 @@ +class MockPool { + constructor(_config) { + this._config = _config; + this._listeners = {}; + this.totalCount = 0; + this.idleCount = 0; + } + + on(event, fn) { + if (!this._listeners[event]) this._listeners[event] = []; + this._listeners[event].push(fn); + return this; + } + + _emit(event, ...args) { + (this._listeners[event] || []).forEach((fn) => fn(...args)); + } + + async connect() { + this.totalCount++; + this.idleCount++; + return { + query: async () => ({ rows: [{ '?column?': 1 }] }), + release: () => { + this.totalCount = Math.max(0, this.totalCount - 1); + this.idleCount = Math.max(0, this.idleCount - 1); + }, + }; + } + + async end() { + this.totalCount = 0; + this.idleCount = 0; + } +} + +module.exports = { Pool: MockPool }; diff --git a/db/003_idempotency_keys.sql b/db/003_idempotency_keys.sql new file mode 100644 index 00000000..4111701b --- /dev/null +++ b/db/003_idempotency_keys.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS idempotency_keys ( + key TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL, + request_hash TEXT NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + status_code INTEGER NOT NULL DEFAULT 0, + response_body TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL +); diff --git a/db/014_idempotency_cleanup_index.sql b/db/014_idempotency_cleanup_index.sql new file mode 100644 index 00000000..12c6d7e2 --- /dev/null +++ b/db/014_idempotency_cleanup_index.sql @@ -0,0 +1,4 @@ +-- Migration 014: add created_at index to idempotency_keys +-- Speeds up the background cleanup job that deletes rows older than 24 hours. + +CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created_at ON idempotency_keys (created_at); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..36f52df9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: scoutoff_test + POSTGRES_USER: test + POSTGRES_PASSWORD: test + ports: + - '5432:5432' + volumes: + - pgdata:/var/lib/postgresql/data + + pgbouncer: + image: edoburu/pgbouncer:1.23.1 + environment: + DATABASE_URL: postgresql://test:test@postgres:5432/scoutoff_test + POOL_MODE: statement + MAX_CLIENT_CONN: 100 + DEFAULT_POOL_SIZE: 20 + SERVER_IDLE_TIMEOUT: 30 + ports: + - '6432:5432' + depends_on: + - postgres + +volumes: + pgdata: diff --git a/docs/postgres-migration.md b/docs/postgres-migration.md new file mode 100644 index 00000000..41f3f63c --- /dev/null +++ b/docs/postgres-migration.md @@ -0,0 +1,116 @@ +# PostgreSQL Migration Guide + +This document covers configuring ScoutOff to use PostgreSQL instead of (or alongside) SQLite. + +## Environment Variables + +Add these to your `.env` file: + +```env +# Connection string (required for PostgreSQL mode) +DATABASE_URL=postgresql://user:password@localhost:5432/scoutoff + +# SSL mode: 'true', 'no-verify', or 'false' (default) +DATABASE_SSL=false + +# Pool sizing — tune for your workload +DB_POOL_MIN=2 +DB_POOL_MAX=10 +``` + +## Pool Sizing + +| Environment | `DB_POOL_MIN` | `DB_POOL_MAX` | Rationale | +|---|---|---|---| +| Development | 0 | 2 | Minimal footprint | +| Staging | 2 | 10 | Moderate load | +| Production | 5 | 20 | High-concurrency API | + +The pool will queue requests beyond `DB_POOL_MAX` rather than rejecting them. If a connection cannot be acquired within 5 seconds (`connectionTimeoutMillis`), the query fails with a timeout error. + +## Connection Health + +- **Idle timeout**: connections idle for 30 seconds are automatically released back to the pool. +- **Connection timeout**: acquiring a connection times out after 5 seconds under load. +- **Error handling**: pool error events increment the `db_pool_error_total` Prometheus counter and are logged. + +## PgBouncer Compatibility + +The driver is compatible with PgBouncer in **statement mode**: + +- Uses `options: '--client_encoding=UTF8'` instead of `SET client_encoding` statements. +- No `SET` commands are issued within transaction scope. +- Connect via PgBouncer on port 6432 (default) instead of PostgreSQL directly. + +### Docker Compose Setup + +```bash +docker-compose up -d +# PostgreSQL on localhost:5432 +# PgBouncer on localhost:6432 (statement mode) +``` + +Update `DATABASE_URL` to point to PgBouncer: + +```env +DATABASE_URL=postgresql://test:test@localhost:6432/scoutoff_test +``` + +## SSL Modes + +| `DATABASE_SSL` | Behavior | +|---|---| +| `false` | No SSL (local development) | +| `true` | SSL with certificate verification | +| `no-verify` | SSL without certificate verification (testing) | + +## Health Checks + +### Readiness Probe (`GET /ready`) + +When `DATABASE_URL` is set, the readiness probe checks PostgreSQL connectivity: + +```json +{ + "status": "ok", + "services": { + "ipfs": "ok", + "postgres": "ok", + "stellar": "ok" + } +} +``` + +Returns `503` with `"postgres": "unavailable"` if the database is unreachable. + +### Prometheus Metrics + +Exposed at `GET /metrics`: + +| Metric | Type | Description | +|---|---|---| +| `db_pool_active_connections` | Gauge | Currently checked-out connections | +| `db_pool_idle_connections` | Gauge | Connections available in the pool | +| `db_pool_error_total` | Counter | Total pool error events | + +## Migrating from SQLite + +1. Install PostgreSQL and create the database. +2. Run the SQL migrations in `db/` against the new database (adapt `AUTOINCREMENT` → `SERIAL`, etc.). +3. Set `DATABASE_URL` in `.env`. +4. The readiness probe will automatically include PostgreSQL checks. + +## Running Tests + +Unit tests mock the `pg` library via `__mocks__/pg.js` — no real database connection is needed: + +```bash +npm run test +``` + +For integration testing with a real database: + +```bash +docker-compose up -d +DATABASE_URL=postgresql://test:test@localhost:5432/scoutoff_test npm run test +``` diff --git a/docs/runbooks/dependency-outages.md b/docs/runbooks/dependency-outages.md new file mode 100644 index 00000000..d8bdc78d --- /dev/null +++ b/docs/runbooks/dependency-outages.md @@ -0,0 +1,235 @@ +# Runbook: External Dependency Outages (Stellar RPC & IPFS/Pinata) + +This document provides detection, mitigation, recovery, and communication instructions for on-call engineers managing outages of external dependencies in the ScoutOff platform. + +The ScoutOff backend relies on two major external systems: + +1. **Stellar Network / Soroban RPC**: For indexing contract events, verifying milestones, registrations, and pay-to-contact settlements. +2. **IPFS / Pinata Gateway**: For pinning and storing player profile metadata, photos, highlight reels, and validator evidence. + +--- + +## 1. Stellar RPC Outage + +> [!WARNING] +> During a Stellar RPC outage, write operations (player registration, milestone submissions, contact payments) will fail. However, read operations (browsing profiles, filtering, search caching) will continue to work normally because they read from the local SQLite index. + +### Detection Signals + +#### Health & Readiness Endpoints + +- **Liveness probe (`GET /health`)**: + Returns HTTP `200 OK` but contains `"stellar": "error"` in the response body. + ```json + { + "status": "ok", + "healthStatus": { + "stellar": "error" + } + } + ``` +- **Readiness probe (`GET /ready` or `GET /health/readiness`)**: + Returns HTTP `503 Service Unavailable` with `status: "degraded"`. + ```json + { + "status": "degraded", + "services": { + "ipfs": "ok", + "stellar": "unavailable" + } + } + ``` + +#### Log Patterns + +Check system logs (`stderr`/`stdout`) for the following patterns: + +- **Event Indexer errors** (emitted every 5 seconds by the indexer loop): + `[error] Indexer error: ` + Common messages: + - `[error] Indexer error: fetch failed` + - `[error] Indexer error: request failed with status code 503` + - `[error] Indexer error: getaddrinfo ENOTFOUND soroban-testnet.stellar.org` +- **Route / Controller errors** (logged by global Express error handler): + `console.error` logs from failed transactions or signature checks: + - `[error] network error` or `PaymentError: NETWORK_ERROR` + +### Immediate Mitigation Options + +#### Option A: Bypass Stellar Health Check (Keep service marked ready) + +By default, an RPC outage causes `/ready` to return `503`, which may cause Kubernetes or your cloud load balancer to kill/route traffic away from the backend container, resulting in a full service outage. +To keep the server marked healthy for read-only traffic (non-chain features): + +1. Locate the environment variables or `.env` file on the server. +2. Set or update: + ```env + STELLAR_HEALTH_CHECK=false + ``` +3. Restart the backend process: + ```bash + # If running via PM2: + pm2 restart scout-off-backend + # If running via systemd: + systemctl restart scout-off-backend + # If running in Docker: + docker restart + ``` +4. Verify `/ready` now returns `200 OK` with `"stellar": "disabled"`: + ```json + { + "status": "ok", + "services": { + "ipfs": "ok", + "stellar": "disabled" + } + } + ``` + +#### Option B: Failover to Backup RPC Nodes + +If the public SDF RPC endpoint (`https://soroban-testnet.stellar.org`) is offline but other RPC endpoints are healthy (e.g., QuickNode or a private node): + +1. Update `.env` with a backup URL: + ```env + SOROBAN_RPC_URL=https:// + # Update Horizon if Horizon is also down + HORIZON_URL=https:// + ``` +2. Restart the backend process. +3. Check the startup health logs: + `[info] Startup health: {"ipfs":"ok","stellar":"ok"}` + +### Recovery Verification + +Before reverting any mitigation (like setting `STELLAR_HEALTH_CHECK` back to `true`), verify the RPC network has fully recovered: + +1. Manually query the configured RPC url using curl: + ```bash + curl -X POST -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"getLatestLedger"}' \ + https://soroban-testnet.stellar.org + ``` + Verify you receive a valid JSON response containing `sequence` and `protocolVersion`. +2. Once the RPC responds, restore the config in `.env`: + ```env + STELLAR_HEALTH_CHECK=true + ``` +3. Restart the backend process and verify `GET /ready` returns: + ```json + { + "status": "ok", + "services": { + "ipfs": "ok", + "stellar": "ok" + } + } + ``` + +--- + +## 2. IPFS / Pinata Outage + +> [!IMPORTANT] +> When IPFS/Pinata is down, players cannot complete registration because metadata JSON pinning fails, and validators cannot submit new milestones (evidence upload fails). + +### Detection Signals + +#### Health & Readiness Endpoints + +- **Readiness probe (`GET /ready` or `GET /health/readiness`)**: + Returns HTTP `503 Service Unavailable` with `status: "degraded"` and `services.ipfs` marked `unavailable`. + ```json + { + "status": "degraded", + "services": { + "ipfs": "unavailable", + "stellar": "ok" + } + } + ``` + +#### Log Patterns + +Check system logs for errors thrown during pinning: + +- **Axios error logs** from IPFS service: + - `console.error` logs with messages: + - `request failed with status code 503` (or 502/504 Bad Gateway from Pinata API) + - `getaddrinfo ENOTFOUND api.pinata.cloud` + - `Error: IPFS connection refused` + +### Immediate Mitigation Options + +#### Option A: Enable IPFS Mock/Stub Mode + +If Pinata is experiencing a genuine prolonged outage, you can temporarily enable **IPFS Stub Mode**. This bypasses Axios network requests to Pinata and returns a valid static CID (`QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG`), allowing registrations and milestone submissions to proceed (using stubbed data). + +1. Open the server's `.env` configuration file. +2. Add or update the following environment variable: + ```env + IPFS_STUB_MODE=true + ``` +3. Restart the backend process: + ```bash + pm2 restart scout-off-backend + ``` +4. Verify `/ready` now returns `200 OK` (with `"ipfs": "ok"` mocked): + ```json + { + "status": "ok", + "services": { + "ipfs": "ok", + "stellar": "ok" + } + } + ``` +5. Test a registration or milestone submission. It should succeed immediately, returning the mock CID. + +### Recovery Verification + +1. To check if the Pinata service is back, manually test the authentication API endpoint using curl: + ```bash + curl -H "pinata_api_key: " \ + -H "pinata_secret_api_key: " \ + https://api.pinata.cloud/data/testAuthentication + ``` + If it returns `{"message":"Congratulations! You are communicating with the Pinata API!"}`, Pinata has recovered. +2. Disable the IPFS stub mode in `.env`: + ```env + IPFS_STUB_MODE=false + ``` +3. Restart the backend process. +4. Verify `GET /ready` returns HTTP `200 OK` with all actual services listed as `"ok"`. + +--- + +## 3. Communication Playbook + +In the event of an outage, communicate the status promptly to platform users and stakeholders: + +### Pre-written Notification Templates + +#### For Stellar RPC Outage (Degraded/Read-Only Mode) + +- **Channel**: Twitter/X, Discord Announcement, or Banner in Frontend +- **Message**: + > **ScoutOff Infrastructure Notice** ⚠️ + > The Stellar network node we use is currently experiencing connection issues. + > + > - **What is working**: You can still log in, browse player profiles, view validator history, and search positions. + > - **What is paused**: New player registrations, validator approvals, and pay-to-contact transactions are temporarily unavailable. + > + > Our engineers are monitoring the situation and will restore full transaction capability as soon as the RPC node is back online. Thank you for your patience! + +#### For IPFS/Pinata Outage (Degraded Mode) + +- **Channel**: Twitter/X, Discord Announcement, or Banner in Frontend +- **Message**: + > **ScoutOff Storage Service Interruption** ⚠️ + > Our media storage provider (Pinata/IPFS) is currently experiencing an outage. + > + > - **What is working**: You can search profiles, view existing cached vitals, and initiate scout contacts. + > - **What is paused**: Uploading new highlight videos, pinning profile updates, and submitting new milestone evidence. + > + > We have enabled a temporary fallback service so player registration forms can still submit, but files/images will not preview until our storage partner recovers. diff --git a/package-lock.json b/package-lock.json index 14044826..5232b3c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,8 @@ "form-data": "4.0.0", "jsonwebtoken": "9.0.2", "node-fetch": "3.3.2", + "pg": "8.13.1", + "prom-client": "15.1.3", "zod": "3.23.8" }, "devDependencies": { @@ -26,8 +28,14 @@ "@types/jest": "29.5.12", "@types/jsonwebtoken": "9.0.6", "@types/node": "20.12.7", + "@types/pg": "8.11.10", "@types/supertest": "6.0.2", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "eslint": "^8.57.1", + "eslint-config-prettier": "^10.1.8", "jest": "29.7.0", + "prettier": "^3.9.5", "supertest": "7.0.0", "ts-jest": "29.1.2", "ts-node-dev": "2.0.0", @@ -604,6 +612,187 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "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/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/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@eslint/eslintrc/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/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/eslintrc/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/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/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/@humanwhocodes/config-array/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/@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/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -986,6 +1175,53 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -1327,6 +1563,18 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/pg": { + "version": "8.11.10", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", + "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^4.0.1" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -1369,60 +1617,374 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==", + "node_modules/@types/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/strip-json-comments": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", + "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/superagent": { + "version": "8.1.10", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz", + "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.2.tgz", + "integrity": "sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/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/@typescript-eslint/parser/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/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/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/@typescript-eslint/type-utils/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/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/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/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, - "license": "MIT" + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, - "node_modules/@types/strip-json-comments": { - "version": "0.0.30", - "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", - "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", + "node_modules/@typescript-eslint/typescript-estree/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/@types/superagent": { - "version": "8.1.10", - "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz", - "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "MIT", - "dependencies": { - "@types/cookiejar": "^2.1.5", - "@types/methods": "^1.1.4", - "@types/node": "*", - "form-data": "^4.0.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, - "node_modules/@types/supertest": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.2.tgz", - "integrity": "sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==", + "node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", "dev": true, "license": "MIT", "dependencies": { - "@types/methods": "^1.1.4", - "@types/superagent": "^8.1.0" + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" } }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", "dev": true, "license": "MIT", "dependencies": { - "@types/yargs-parser": "*" + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", "dev": true, - "license": "MIT" + "license": "ISC" }, "node_modules/accepts": { "version": "1.3.8", @@ -1450,6 +2012,16 @@ "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/acorn-walk": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", @@ -1498,6 +2070,23 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -1577,6 +2166,16 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -1867,6 +2466,12 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -2468,6 +3073,13 @@ "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -2573,6 +3185,32 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dotenv": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", @@ -2703,51 +3341,302 @@ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0" + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/eslint/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/eslint/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/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/eslint/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/eslint/node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" }, - "engines": { - "node": ">= 0.4" + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "node_modules/eslint/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": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "p-locate": "^5.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/eslint/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/eslint/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": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, "engines": { - "node": ">=8" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/esprima": { @@ -2764,6 +3653,52 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -2883,6 +3818,30 @@ "node": ">= 0.10.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-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.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", @@ -2890,6 +3849,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -2897,6 +3863,16 @@ "dev": true, "license": "MIT" }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -2930,6 +3906,19 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -2981,6 +3970,45 @@ "node": ">=8" } }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flat-cache/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -3236,6 +4264,56 @@ "node": ">= 6" } }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globals/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3255,6 +4333,13 @@ "dev": true, "license": "ISC" }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3417,6 +4502,43 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -3581,6 +4703,16 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4361,6 +5493,13 @@ "node": ">=6" } }, + "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-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -4368,6 +5507,20 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4442,6 +5595,16 @@ "safe-buffer": "^5.0.1" } }, + "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/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -4462,6 +5625,20 @@ "node": ">=6" } }, + "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/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4525,6 +5702,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -4618,6 +5802,16 @@ "dev": true, "license": "MIT" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -4888,6 +6082,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true, + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4916,13 +6117,31 @@ "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^2.1.0" + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "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": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.8.0" } }, "node_modules/p-limit": { @@ -4980,6 +6199,19 @@ "node": ">=6" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -5051,6 +6283,170 @@ "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pg": { + "version": "8.13.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", + "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-numeric": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", + "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.1.0.tgz", + "integrity": "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.1.0", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pg/node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg/node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pg/node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pg/node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pgpass": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.6.tgz", + "integrity": "sha512-lqIfH7bdgsxHAY/ZnUOwm+aCFKrsHBDhSFuk9O0B9uCqJAIkrKTo/+LQqLPLUS4e04+jCmQVikxE3QipH5chPw==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5103,6 +6499,56 @@ "node": ">= 0.4" } }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "obuf": "~1.1.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postgres-date": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-interval": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-range": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", + "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", + "dev": true, + "license": "MIT" + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -5130,6 +6576,32 @@ "node": ">=10" } }, + "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.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -5158,6 +6630,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -5201,6 +6686,16 @@ "once": "^1.3.1" } }, + "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/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -5233,6 +6728,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -5402,6 +6918,17 @@ "node": ">=10" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", @@ -5416,6 +6943,30 @@ "rimraf": "bin.js" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -5977,6 +7528,15 @@ "node": ">=6" } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -5992,6 +7552,13 @@ "node": ">=8" } }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -6051,6 +7618,19 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/ts-jest": { "version": "29.1.2", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.2.tgz", @@ -6238,6 +7818,19 @@ "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", "license": "Unlicense" }, + "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/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -6349,6 +7942,16 @@ "browserslist": ">= 4.21.0" } }, + "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/urijs": { "version": "1.19.11", "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", @@ -6457,6 +8060,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -6499,7 +8112,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4" diff --git a/package.json b/package.json index a48f16ec..9e491883 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ "build": "tsc", "start": "node dist/index.js", "test": "jest --runInBand", - "lint": "eslint 'src/**/*.ts' 'tests/**/*.ts' --ext .ts" + "lint": "eslint 'src/**/*.ts' 'tests/**/*.ts' --ext .ts", + "format:check": "prettier --check .", + "format": "prettier --write .", + "purge:idempotency-keys": "npm run build && node dist/scripts/purgeIdempotencyKeys.js" }, "dependencies": { "@stellar/stellar-sdk": "12.1.0", @@ -20,6 +23,8 @@ "form-data": "4.0.0", "jsonwebtoken": "9.0.2", "node-fetch": "3.3.2", + "pg": "8.13.1", + "prom-client": "15.1.3", "zod": "3.23.8" }, "devDependencies": { @@ -29,8 +34,14 @@ "@types/jest": "29.5.12", "@types/jsonwebtoken": "9.0.6", "@types/node": "20.12.7", + "@types/pg": "8.11.10", "@types/supertest": "6.0.2", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "eslint": "^8.57.1", + "eslint-config-prettier": "^10.1.8", "jest": "29.7.0", + "prettier": "^3.9.5", "supertest": "7.0.0", "ts-jest": "29.1.2", "ts-node-dev": "2.0.0", @@ -39,10 +50,15 @@ "jest": { "preset": "ts-jest", "testEnvironment": "node", - "testMatch": ["**/tests/**/*.test.ts"], - "setupFiles": ["/tests/setup.ts"], + "testMatch": [ + "**/tests/**/*.test.ts" + ], + "setupFiles": [ + "/tests/setup.ts" + ], "moduleNameMapper": { - "^better-sqlite3$": "/__mocks__/better-sqlite3.js" + "^better-sqlite3$": "/__mocks__/better-sqlite3.js", + "^pg$": "/__mocks__/pg.js" } } } diff --git a/scripts/validate-env.js b/scripts/validate-env.js index 70b58196..39e23cc6 100644 --- a/scripts/validate-env.js +++ b/scripts/validate-env.js @@ -39,7 +39,7 @@ if (process.argv.includes('--runtime')) { } if (errors.length) { - errors.forEach(e => console.error(`[env] ERROR: ${e}`)); + errors.forEach((e) => console.error(`[env] ERROR: ${e}`)); process.exit(1); } @@ -50,15 +50,17 @@ if (process.argv.includes('--runtime')) { // ─── CI / documentation check ──────────────────────────────────────────────── const examplePath = path.resolve(__dirname, '../.env.example'); const exampleKeys = new Set( - fs.readFileSync(examplePath, 'utf8') + fs + .readFileSync(examplePath, 'utf8') .split('\n') - .filter(l => l && !l.startsWith('#')) - .map(l => l.split('=')[0].trim()) + .filter((l) => l && !l.startsWith('#')) + .map((l) => l.split('=')[0].trim()) ); -const srcFiles = fs.readdirSync(path.resolve(__dirname, '../src'), { recursive: true }) - .filter(f => f.endsWith('.ts')) - .map(f => path.resolve(__dirname, '../src', f)); +const srcFiles = fs + .readdirSync(path.resolve(__dirname, '../src'), { recursive: true }) + .filter((f) => f.endsWith('.ts')) + .map((f) => path.resolve(__dirname, '../src', f)); const missing = []; for (const file of srcFiles) { diff --git a/src/config.ts b/src/config.ts index 734604b5..66df2c07 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,15 +19,26 @@ const ConfigSchema = z.object({ dbPath: z.string().default('scout-off.db'), }); +function required(key: string): string { + const value = process.env[key]; + if (!value) { + throw new Error(`Missing required environment variable: ${key}`); + } + return value; +} + +function parseDatabaseSsl(value: string): false | { rejectUnauthorized: boolean } { + if (value === 'true') return { rejectUnauthorized: true }; + if (value === 'no-verify') return { rejectUnauthorized: false }; + return false; +} + const config = { port: parseInt(process.env.PORT ?? '4000', 10), network: (process.env.NETWORK ?? 'testnet') as 'testnet' | 'mainnet', - networkPassphrase: - process.env.NETWORK_PASSPHRASE ?? 'Test SDF Network ; September 2015', - horizonUrl: - process.env.HORIZON_URL ?? 'https://horizon-testnet.stellar.org', - sorobanRpcUrl: - process.env.SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org', + networkPassphrase: process.env.NETWORK_PASSPHRASE ?? 'Test SDF Network ; September 2015', + horizonUrl: process.env.HORIZON_URL ?? 'https://horizon-testnet.stellar.org', + sorobanRpcUrl: process.env.SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org', contractId: required('CONTRACT_ID'), jwtSecret: required('JWT_SECRET'), pinata: { @@ -39,6 +50,7 @@ const config = { dbPath: process.env.DB_PATH ?? 'scout-off.db', logLevel: (process.env.LOG_LEVEL ?? 'info') as 'debug' | 'info' | 'warn' | 'error', stellarHealthCheckEnabled: process.env.STELLAR_HEALTH_CHECK !== 'false', + ipfsStubMode: process.env.IPFS_STUB_MODE === 'true', adminWallet: process.env.ADMIN_WALLET ?? '', securityHeaders: { hsts: process.env.SECURITY_HSTS ?? 'max-age=31536000; includeSubDomains', @@ -46,16 +58,23 @@ const config = { xFrameOptions: process.env.SECURITY_X_FRAME_OPTIONS ?? 'DENY', referrerPolicy: process.env.SECURITY_REFERRER_POLICY ?? 'no-referrer', }, - logLevel: (process.env.LOG_LEVEL ?? 'info') as 'debug' | 'info' | 'warn' | 'error', webhook: { enabled: process.env.WEBHOOK_ENABLED === 'true', - url: process.env.WEBHOOK_URL ?? '' + url: process.env.WEBHOOK_URL ?? '', }, rateLimit: { enabled: process.env.RATE_LIMIT_ENABLED === 'true', windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? '60000', 10), max: parseInt(process.env.RATE_LIMIT_MAX ?? '60', 10), }, + idempotency: { + ttlSeconds: parseInt(process.env.IDEMPOTENCY_TTL_SECONDS ?? '86400', 10), + purgeIntervalMs: parseInt(process.env.IDEMPOTENCY_PURGE_INTERVAL_MS ?? '60000', 10), + }, + databaseUrl: process.env.DATABASE_URL ?? '', + databaseSsl: parseDatabaseSsl(process.env.DATABASE_SSL ?? 'false'), + dbPoolMin: parseInt(process.env.DB_POOL_MIN ?? '2', 10), + dbPoolMax: parseInt(process.env.DB_POOL_MAX ?? '10', 10), }; export default config; diff --git a/src/controllers/adminController.ts b/src/controllers/adminController.ts index 5d3650a2..f35a4146 100644 --- a/src/controllers/adminController.ts +++ b/src/controllers/adminController.ts @@ -1,8 +1,11 @@ import { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; +import jwt from 'jsonwebtoken'; import { getEvents } from '../services/indexer'; -import { AdminEvent, FeeHistoryItem, ApiResponse } from '../types'; +import { AdminEvent, FeeHistoryItem, ApiResponse, EventRecord } from '../types'; import config from '../config'; +import { logAuditEvent } from '../services/audit'; +import { logger } from '../utils/logger'; const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{55}$/; @@ -43,14 +46,16 @@ export async function getAllEvents(req: Request, res: Response, next: NextFuncti /** GET /api/admin/fees — returns fees_withdrawn event payloads */ export async function getFeeSummary(req: Request, res: Response, next: NextFunction) { try { - const adminWallet = (req as any).account as string ?? 'unknown'; + const adminWallet = ((req as any).account as string) ?? 'unknown'; logAuditEvent({ action: 'fee_history_query', adminWallet, queryParams: req.query as Record, timestamp: new Date().toISOString(), }); - const withdrawals = getEvents('fees_withdrawn').map((e) => e.payload as Record); + const withdrawals = getEvents('fees_withdrawn').map( + (e) => e.payload as Record + ); const body: ApiResponse[]> = { success: true, data: withdrawals }; res.json(body); } catch (err) { @@ -65,14 +70,22 @@ export async function registerValidator(req: Request, res: Response, next: NextF const { validatorWallet } = req.body as { validatorWallet?: string }; if (!validatorWallet || !STELLAR_ADDRESS_RE.test(validatorWallet)) { - console.warn(`[admin] register_validator rejected — invalid address | admin=${adminWallet} target=${validatorWallet}`); - res.status(400).json({ success: false, error: 'validatorWallet must be a valid Stellar address' }); + console.warn( + `[admin] register_validator rejected — invalid address | admin=${adminWallet} target=${validatorWallet}` + ); + res + .status(400) + .json({ success: false, error: 'validatorWallet must be a valid Stellar address' }); return; } - console.info(`[admin] action=register_validator admin=${adminWallet} target=${validatorWallet}`); + console.info( + `[admin] action=register_validator admin=${adminWallet} target=${validatorWallet}` + ); // TODO: invoke register_validator on Soroban contract - res.status(202).json({ success: true, message: `Validator ${validatorWallet} registration submitted` }); + res + .status(202) + .json({ success: true, message: `Validator ${validatorWallet} registration submitted` }); } catch (err) { next(err); } @@ -85,14 +98,20 @@ export async function revokeValidator(req: Request, res: Response, next: NextFun const { validatorWallet } = req.body as { validatorWallet?: string }; if (!validatorWallet || !STELLAR_ADDRESS_RE.test(validatorWallet)) { - console.warn(`[admin] revoke_validator rejected — invalid address | admin=${adminWallet} target=${validatorWallet}`); - res.status(400).json({ success: false, error: 'validatorWallet must be a valid Stellar address' }); + console.warn( + `[admin] revoke_validator rejected — invalid address | admin=${adminWallet} target=${validatorWallet}` + ); + res + .status(400) + .json({ success: false, error: 'validatorWallet must be a valid Stellar address' }); return; } console.info(`[admin] action=revoke_validator admin=${adminWallet} target=${validatorWallet}`); // TODO: invoke revoke_validator on Soroban contract - res.status(202).json({ success: true, message: `Validator ${validatorWallet} revocation submitted` }); + res + .status(202) + .json({ success: true, message: `Validator ${validatorWallet} revocation submitted` }); } catch (err) { next(err); } diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts index 4d23b76e..5dc7bf6e 100644 --- a/src/controllers/authController.ts +++ b/src/controllers/authController.ts @@ -8,7 +8,14 @@ const TOKEN_TTL_SECONDS = 86400; const challengeSchema = z.object({ account: z.string().refine( - (val) => { try { Keypair.fromPublicKey(val); return true; } catch { return false; } }, + (val) => { + try { + Keypair.fromPublicKey(val); + return true; + } catch { + return false; + } + }, { message: 'Invalid Stellar public key' } ), }); @@ -35,16 +42,16 @@ export function postToken(req: Request, res: Response, next: NextFunction): void const { transaction, role } = tokenSchema.parse(req.body); // Seed admin: if the authenticated wallet matches ADMIN_WALLET, always issue admin role const candidate = extractAccount(transaction); - const effectiveRole = - config.adminWallet && candidate === config.adminWallet ? 'admin' : role; + const effectiveRole = config.adminWallet && candidate === config.adminWallet ? 'admin' : role; const { token, account } = verifyAndIssueToken(transaction, effectiveRole); const expiresAt = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS; res.json({ token, account, expiresAt }); } catch (err) { - if (err instanceof Error && ( - err.message === 'Invalid challenge signature' || - err.message === 'Missing source account in challenge' - )) { + if ( + err instanceof Error && + (err.message === 'Invalid challenge signature' || + err.message === 'Missing source account in challenge') + ) { res.status(401).json({ success: false, error: err.message }); return; } diff --git a/src/controllers/playerController.ts b/src/controllers/playerController.ts index 0ec48dc9..21dabaa7 100644 --- a/src/controllers/playerController.ts +++ b/src/controllers/playerController.ts @@ -1,10 +1,16 @@ +import { Request, Response, NextFunction } from 'express'; import { sanitizeInput } from '../utils/sanitizer'; import { z } from 'zod'; import { pinJson, gatewayUrl } from '../services/ipfs'; import { getEvents } from '../services/indexer'; import { invalidatePlayerCache } from '../services/cache'; +import { dispatchEventWebhook } from '../services/webhooks'; import { ApiResponse, ProgressLevel } from '../types'; import { getTierMeta } from '../utils/tier'; +import { validateMinTier } from '../utils/minTierValidator'; +import { normalizePosition } from '../utils/positionAliases'; + +const CID_REGEX = /^Qm[1-9A-HJ-NP-Za-km-z]{44}$/; const baseRegistrationSchema = z.object({ wallet: z.string().min(56).max(56), @@ -36,14 +42,15 @@ export async function registerPlayer(req: Request, res: Response, next: NextFunc const parsed = registerSchema.parse(req.body); const sanitizedPosition = sanitizeInput(parsed.position); const sanitizedRegion = sanitizeInput(parsed.region); - const metadataUri = 'metadataUri' in parsed - ? parsed.metadataUri - : await pinJson({ - wallet: parsed.wallet, - position: sanitizedPosition, - region: sanitizedRegion, - ...parsed.metadata, - }); + const metadataUri = + 'metadataUri' in parsed + ? parsed.metadataUri + : await pinJson({ + wallet: parsed.wallet, + position: sanitizedPosition, + region: sanitizedRegion, + ...parsed.metadata, + }); // Invalidate player search cache so new profile appears in results invalidatePlayerCache(); @@ -68,9 +75,7 @@ export async function registerPlayer(req: Request, res: Response, next: NextFunc export async function getPlayer(req: Request, res: Response, next: NextFunction) { try { const playerId = sanitizeInput(req.params.playerId); - const events = getEvents('player_registered').filter( - (e) => e.payload.player_id === playerId - ); + const events = getEvents('player_registered').filter((e) => e.payload.player_id === playerId); if (!events.length) { res.status(404).json({ success: false, error: 'Player not found' }); return; @@ -92,6 +97,7 @@ export async function filterPlayers(req: Request, res: Response, next: NextFunct res.status(400).json({ success: false, error: tierResult.error }); return; } + const minTier = tierResult.tier; const { region, position, page, pageSize } = filterSchema.parse(req.query); const sanitizedRegion = region ? sanitizeInput(region) : undefined; const sanitizedPosition = position ? sanitizeInput(position) : undefined; @@ -106,8 +112,7 @@ export async function filterPlayers(req: Request, res: Response, next: NextFunct const match = normalizedPosition ?? sanitizedPosition; players = players.filter((p) => p.position === match); } - if (minTier !== undefined) - players = players.filter((p) => Number(p.progress_level) >= minTier); + if (minTier !== undefined) players = players.filter((p) => Number(p.progress_level) >= minTier); const total = players.length; const pages = Math.ceil(total / pageSize); const paginated = players.slice((page - 1) * pageSize, page * pageSize); diff --git a/src/controllers/scoutController.ts b/src/controllers/scoutController.ts index c812643c..f1d92458 100644 --- a/src/controllers/scoutController.ts +++ b/src/controllers/scoutController.ts @@ -14,7 +14,10 @@ export async function getSubscription(req: Request, res: Response, next: NextFun const subs = getEvents('scout_subscribed').filter((e) => e.payload.scout === wallet); const latest = subs.at(-1); if (!latest) { - res.json({ success: true, data: { active: false, tier: null, expiresAt: null, remainingDays: 0 } }); + res.json({ + success: true, + data: { active: false, tier: null, expiresAt: null, remainingDays: 0 }, + }); return; } const expiresAt = latest.payload.subscriptionExpiry as number; diff --git a/src/controllers/validatorController.ts b/src/controllers/validatorController.ts index 0553cbd2..12bb90a3 100644 --- a/src/controllers/validatorController.ts +++ b/src/controllers/validatorController.ts @@ -4,6 +4,7 @@ import { pinJson } from '../services/ipfs'; import { getEvents } from '../services/indexer'; import { invalidateMilestoneCache } from '../services/cache'; import { PlayerMilestone } from '../types'; +import { logger } from '../utils/logger'; export const milestoneSchema = z.object({ playerId: z.string().min(1), @@ -18,6 +19,7 @@ export const pendingQuerySchema = z.object({ /** POST /api/validators/milestone */ function getCorrelationId(req: Request): string { + if (!req.headers) return 'none'; return String(req.headers['x-correlation-id'] ?? req.headers['correlation-id'] ?? 'none'); } @@ -45,17 +47,16 @@ export async function getPendingMilestones(req: Request, res: Response, next: Ne try { const { region, playerId } = pendingQuerySchema.parse(req.query); const submitted = getEvents('milestone_submitted').map((e) => e.payload); - const approvedIds = new Set( - getEvents('milestone_approved').map((e) => e.payload.milestone_id) - ); + const approvedIds = new Set(getEvents('milestone_approved').map((e) => e.payload.milestone_id)); let pending = submitted.filter((m) => !approvedIds.has(m.milestone_id)); if (region) pending = pending.filter((m) => m.region === region); - if (playerId) pending = pending.filter((m) => m.playerId === playerId || m.player_id === playerId); + if (playerId) + pending = pending.filter((m) => m.playerId === playerId || m.player_id === playerId); const milestones: PlayerMilestone[] = pending.map((m) => ({ status: 'pending' as const, - approvedBy: m.validator as string || '', - submittedAt: m.created_at as number || Math.floor(Date.now() / 1000), - evidenceUri: m.evidence_uri as string || m.evidenceUri as string || '', + approvedBy: (m.validator as string) || '', + submittedAt: (m.created_at as number) || Math.floor(Date.now() / 1000), + evidenceUri: (m.evidence_uri as string) || (m.evidenceUri as string) || '', })); res.json({ success: true, data: milestones }); } catch (err) { diff --git a/src/db/postgres-driver.ts b/src/db/postgres-driver.ts new file mode 100644 index 00000000..5dc4ec92 --- /dev/null +++ b/src/db/postgres-driver.ts @@ -0,0 +1,63 @@ +import { Pool, PoolConfig } from 'pg'; +import config from '../config'; +import { logger } from '../utils/logger'; +import { dbPoolActiveConnections, dbPoolIdleConnections, dbPoolErrorTotal } from '../middleware/metrics'; + +let pool: Pool | null = null; + +function buildPoolConfig(): PoolConfig { + const pc: PoolConfig = { + connectionString: config.databaseUrl || undefined, + min: config.dbPoolMin, + max: config.dbPoolMax, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + options: '--client_encoding=UTF8', + }; + + if (config.databaseSsl) { + pc.ssl = config.databaseSsl; + } + + return pc; +} + +export function getPool(): Pool { + if (!pool) { + pool = new Pool(buildPoolConfig()); + + pool.on('error', (err) => { + dbPoolErrorTotal.inc(); + logger.error('PostgreSQL pool error:', err.message); + }); + } + return pool; +} + +export function updatePoolMetrics(): void { + const p = pool; + if (!p) return; + try { + dbPoolActiveConnections.set(p.totalCount - p.idleCount); + dbPoolIdleConnections.set(p.idleCount); + } catch { + // Pool may be shut down — ignore metric update + } +} + +export async function poolHealth(): Promise { + const client = await getPool().connect(); + try { + await client.query('SELECT 1'); + } finally { + client.release(); + } +} + +export async function closePool(): Promise { + if (pool) { + const p = pool; + pool = null; + await p.end(); + } +} diff --git a/src/index.ts b/src/index.ts index db39efed..a7164ede 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,10 +9,15 @@ import adminRoutes from './routes/admin'; import { errorHandler } from './middleware/errorHandler'; import { securityHeaders } from './middleware/securityHeaders'; import { correlationId } from './middleware/correlationId'; +import { responseTime } from './middleware/responseTime'; +import { idempotencyMiddleware, startIdempotencyPurgeJob, cleanupDriver } from './middleware/idempotency'; +import { startIdempotencyCleanupJob } from './services/idempotencyCleanup'; import { indexEvents } from './services/indexer'; import { logger } from './utils/logger'; import { stellarHealth } from './services/stellar'; import { checkHealth } from './services/ipfs'; +import { poolHealth, updatePoolMetrics } from './db/postgres-driver'; +import { register as metricsRegister } from './middleware/metrics'; const app = express(); @@ -21,6 +26,7 @@ app.use(correlationId); app.use(securityHeaders); app.use(responseTime); app.use(express.json()); +app.use(idempotencyMiddleware); app.get('/health', async (_req, res) => { const healthStatus: Record = {}; @@ -41,7 +47,7 @@ app.get('/health/liveness', (_req, res) => { res.json({ status: 'ok' }); }); -app.get('/health/readiness', async (_req, res) => { +const readinessHandler = async (_req: express.Request, res: express.Response) => { const services: Record = {}; // Check IPFS/Pinata availability @@ -52,6 +58,18 @@ app.get('/health/readiness', async (_req, res) => { services.ipfs = 'unavailable'; } + // Check PostgreSQL availability + if (config.databaseUrl) { + try { + await poolHealth(); + services.postgres = 'ok'; + } catch { + services.postgres = 'unavailable'; + } + } else { + services.postgres = 'disabled'; + } + // Check Stellar RPC if enabled if (config.stellarHealthCheckEnabled) { try { @@ -64,12 +82,20 @@ app.get('/health/readiness', async (_req, res) => { services.stellar = 'disabled'; } - const allOk = Object.values(services).every(v => v === 'ok' || v === 'disabled'); + const allOk = Object.values(services).every((v) => v === 'ok' || v === 'disabled'); if (allOk) { res.json({ status: 'ok', services }); } else { res.status(503).json({ status: 'degraded', services }); } +}; + +app.get('/health/readiness', readinessHandler); +app.get('/ready', readinessHandler); + +app.get('/metrics', async (_req, res) => { + res.set('Content-Type', metricsRegister.contentType); + res.end(await metricsRegister.metrics()); }); app.use('/auth', authRoutes); @@ -118,6 +144,13 @@ app.listen(config.port, () => { poll(); setInterval(poll, 5_000); + startIdempotencyPurgeJob(); + startIdempotencyCleanupJob(cleanupDriver); + + // Update pool metrics every 10 seconds if PostgreSQL is configured + if (config.databaseUrl) { + setInterval(updatePoolMetrics, 10_000); + } }); export default app; diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index 63450f77..92d6dc97 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -2,16 +2,14 @@ import { Request, Response, NextFunction } from 'express'; import { ZodError } from 'zod'; import { ApiResponse } from '../types'; -export function errorHandler( - err: Error, - _req: Request, - res: Response, - _next: NextFunction -): void { +export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void { console.error(err.message); if (err instanceof ZodError) { - const body: ApiResponse = { success: false, error: err.errors[0]?.message ?? 'Validation error' }; + const body: ApiResponse = { + success: false, + error: err.errors[0]?.message ?? 'Validation error', + }; res.status(400).json(body); return; } diff --git a/src/middleware/idempotency.ts b/src/middleware/idempotency.ts new file mode 100644 index 00000000..14c4a334 --- /dev/null +++ b/src/middleware/idempotency.ts @@ -0,0 +1,167 @@ +import Database from 'better-sqlite3'; +import fs from 'fs'; +import path from 'path'; +import type { NextFunction, Request, Response } from 'express'; +import config from '../config'; +import type { IdempotencyCleanupDriver } from '../services/idempotencyCleanup'; + +export interface IdempotencyRecord { + key: string; + expiresAt: number; + requestHash: string; + method: string; + path: string; + statusCode: number; + responseBody: string; + createdAt: number; +} + +interface IdempotencyRequest extends Request { + idempotencyKey?: string; + idempotencyReplay?: boolean; +} + +const DEFAULT_TTL_SECONDS = 86_400; +const DEFAULT_PURGE_INTERVAL_MS = 60_000; + +let db: InstanceType | undefined; + +function getDatabase(): InstanceType { + if (!db) { + db = new Database(config.dbPath); + initializeDatabase(); + } + + return db; +} + +function initializeDatabase(): void { + const migrationPath = path.resolve(__dirname, '../../db/003_idempotency_keys.sql'); + const migrationSql = fs.readFileSync(migrationPath, 'utf8'); + + getDatabase().exec(migrationSql); + getDatabase().exec(` + CREATE INDEX IF NOT EXISTS idx_idempotency_keys_expires_at + ON idempotency_keys (expires_at); + `); + getDatabase().exec(` + CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created_at + ON idempotency_keys (created_at); + `); +} + +export function getIdempotencyDatabase(): InstanceType { + return getDatabase(); +} + +function getTtlSeconds(): number { + const parsed = Number.parseInt(String(process.env.IDEMPOTENCY_TTL_SECONDS ?? ''), 10); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + return config.idempotency?.ttlSeconds && config.idempotency.ttlSeconds > 0 + ? config.idempotency.ttlSeconds + : DEFAULT_TTL_SECONDS; +} + +function getPurgeIntervalMs(): number { + const parsed = Number.parseInt(String(process.env.IDEMPOTENCY_PURGE_INTERVAL_MS ?? ''), 10); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + return config.idempotency?.purgeIntervalMs && config.idempotency.purgeIntervalMs > 0 + ? config.idempotency.purgeIntervalMs + : DEFAULT_PURGE_INTERVAL_MS; +} + +function isMutatingMethod(method: string): boolean { + return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase()); +} + +function buildRequestHash(req: Request): string { + return `${req.method}:${req.originalUrl || req.url}`; +} + +export function getIdempotencyRecord( + key: string, + now: number = Math.floor(Date.now() / 1000) +): IdempotencyRecord | undefined { + return getDatabase() + .prepare( + 'SELECT key, expires_at AS expiresAt, request_hash AS requestHash, method, path, status_code AS statusCode, response_body AS responseBody, created_at AS createdAt FROM idempotency_keys WHERE key = ? AND expires_at > ?' + ) + .get(key, now) as IdempotencyRecord | undefined; +} + +export function recordIdempotencyKey( + key: string, + req: Request, + expiresAt: number, + now: number = Math.floor(Date.now() / 1000) +): void { + getDatabase() + .prepare( + `INSERT INTO idempotency_keys (key, expires_at, request_hash, method, path, status_code, response_body, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run(key, expiresAt, buildRequestHash(req), req.method, req.originalUrl || req.url, 0, '', now); +} + +export function purgeExpiredIdempotencyKeys(now: number = Math.floor(Date.now() / 1000)): number { + return getDatabase().prepare('DELETE FROM idempotency_keys WHERE expires_at <= ?').run(now) + .changes; +} + +export function startIdempotencyPurgeJob( + intervalMs: number = getPurgeIntervalMs() +): NodeJS.Timeout | undefined { + if (intervalMs <= 0) { + return undefined; + } + + return setInterval(() => { + purgeExpiredIdempotencyKeys(); + }, intervalMs); +} + +export function idempotencyMiddleware( + req: IdempotencyRequest, + _res: Response, + next: NextFunction +): void { + if (!isMutatingMethod(req.method)) { + next(); + return; + } + + const key = req.get('Idempotency-Key') || req.get('idempotency-key'); + if (!key) { + next(); + return; + } + + const now = Math.floor(Date.now() / 1000); + const record = getIdempotencyRecord(key, now); + + if (record) { + req.idempotencyKey = key; + req.idempotencyReplay = true; + next(); + return; + } + + const expiresAt = now + getTtlSeconds(); + recordIdempotencyKey(key, req, expiresAt, now); + + req.idempotencyKey = key; + req.idempotencyReplay = false; + next(); +} + +export const cleanupDriver: IdempotencyCleanupDriver = { + deleteOlderThan(threshold: number): number { + return getDatabase() + .prepare('DELETE FROM idempotency_keys WHERE created_at < ?') + .run(threshold).changes; + }, +}; diff --git a/src/middleware/metrics.ts b/src/middleware/metrics.ts new file mode 100644 index 00000000..61e3d6c9 --- /dev/null +++ b/src/middleware/metrics.ts @@ -0,0 +1,27 @@ +import { Registry, Gauge, Counter } from 'prom-client'; + +export const register = new Registry(); + +export const dbPoolActiveConnections = new Gauge({ + name: 'db_pool_active_connections', + help: 'Number of active (checked-out) PostgreSQL connections in the pool', + registers: [register], +}); + +export const dbPoolIdleConnections = new Gauge({ + name: 'db_pool_idle_connections', + help: 'Number of idle PostgreSQL connections in the pool', + registers: [register], +}); + +export const dbPoolErrorTotal = new Counter({ + name: 'db_pool_error_total', + help: 'Total number of PostgreSQL pool error events', + registers: [register], +}); + +export const idempotencyKeysDeletedTotal = new Counter({ + name: 'idempotency_keys_deleted_total', + help: 'Total number of expired idempotency keys deleted by the cleanup job', + registers: [register], +}); diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index 0886a4b4..19d60f74 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from 'express'; interface RateLimitOptions { windowMs?: number; // time window in ms (default: 60_000) - max?: number; // max requests per window per IP (default: 10) + max?: number; // max requests per window per IP (default: 10) } /** diff --git a/src/middleware/responseTime.ts b/src/middleware/responseTime.ts index 058734c1..bd3441dc 100644 --- a/src/middleware/responseTime.ts +++ b/src/middleware/responseTime.ts @@ -6,8 +6,10 @@ import { Request, Response, NextFunction } from 'express'; */ export function responseTime(req: Request, res: Response, next: NextFunction): void { const start = Date.now(); - res.on('finish', () => { + const originalWriteHead = res.writeHead; + res.writeHead = function (statusCode: any, ...args: any[]) { res.setHeader('X-Response-Time', `${Date.now() - start}ms`); - }); + return originalWriteHead.apply(res, [statusCode, ...args] as any); + } as any; next(); } diff --git a/src/middleware/validate.ts b/src/middleware/validate.ts index 5a643f4e..63215d4e 100644 --- a/src/middleware/validate.ts +++ b/src/middleware/validate.ts @@ -7,6 +7,7 @@ interface ValidationOptions { } function getCorrelationId(req: Request): string { + if (!req.headers) return 'none'; return String(req.headers['x-correlation-id'] ?? req.headers['correlation-id'] ?? 'none'); } diff --git a/src/routes/admin.ts b/src/routes/admin.ts index b759c142..43b33d4f 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,5 +1,12 @@ import { Router } from 'express'; -import { getStats, getAllEvents, getFeeSummary, registerValidator, revokeValidator } from '../controllers/adminController'; +import { + getStats, + getAllEvents, + getFeeSummary, + registerValidator, + revokeValidator, + introspectToken, +} from '../controllers/adminController'; import { requireAuth, requireRole } from '../middleware/auth'; const router = Router(); diff --git a/src/routes/scout.ts b/src/routes/scout.ts index df819345..7b02ece2 100644 --- a/src/routes/scout.ts +++ b/src/routes/scout.ts @@ -1,5 +1,10 @@ import { Router } from 'express'; -import { getSubscription, getUnlockedContacts, unlockContact, getPaymentHistory } from '../controllers/scoutController'; +import { + getSubscription, + getUnlockedContacts, + unlockContact, + getPaymentHistory, +} from '../controllers/scoutController'; import { requireAuth } from '../middleware/auth'; const router = Router(); diff --git a/src/routes/validator.ts b/src/routes/validator.ts index 6c631f60..c2fb00e0 100644 --- a/src/routes/validator.ts +++ b/src/routes/validator.ts @@ -16,7 +16,18 @@ const milestoneRateLimit = rateLimit({ max: Number(process.env.MILESTONE_RATE_MAX) || 10, }); -router.post('/milestone', milestoneRateLimit, requireRole('validator'), validateBody(milestoneSchema), submitMilestoneEvidence); -router.get('/milestones/pending', requireRole('validator'), validateQuery(pendingQuerySchema), getPendingMilestones); +router.post( + '/milestone', + milestoneRateLimit, + requireRole('validator'), + validateBody(milestoneSchema), + submitMilestoneEvidence +); +router.get( + '/milestones/pending', + requireRole('validator'), + validateQuery(pendingQuerySchema), + getPendingMilestones +); export default router; diff --git a/src/scripts/purgeIdempotencyKeys.ts b/src/scripts/purgeIdempotencyKeys.ts new file mode 100644 index 00000000..0362072e --- /dev/null +++ b/src/scripts/purgeIdempotencyKeys.ts @@ -0,0 +1,4 @@ +import { purgeExpiredIdempotencyKeys } from '../middleware/idempotency'; + +const deleted = purgeExpiredIdempotencyKeys(); +console.log(`Deleted ${deleted} expired idempotency key rows.`); diff --git a/src/services/idempotencyCleanup.ts b/src/services/idempotencyCleanup.ts new file mode 100644 index 00000000..d059855e --- /dev/null +++ b/src/services/idempotencyCleanup.ts @@ -0,0 +1,44 @@ +import { logger } from '../utils/logger'; +import { idempotencyKeysDeletedTotal } from '../middleware/metrics'; + +export interface IdempotencyCleanupDriver { + deleteOlderThan(threshold: number): number; +} + +const ONE_HOUR_MS = 60 * 60 * 1000; +const TWENTY_FOUR_HOURS_S = 24 * 60 * 60; + +export function deleteExpiredIdempotencyKeys( + driver: IdempotencyCleanupDriver, + now: number = Math.floor(Date.now() / 1000) +): number { + const threshold = now - TWENTY_FOUR_HOURS_S; + const deleted = driver.deleteOlderThan(threshold); + + if (deleted > 0) { + idempotencyKeysDeletedTotal.inc(deleted); + } + + logger.debug(`Deleted ${deleted} expired idempotency key(s)`); + return deleted; +} + +export function startIdempotencyCleanupJob( + driver: IdempotencyCleanupDriver +): NodeJS.Timeout | undefined { + if (process.env.NODE_ENV === 'test') { + logger.debug('Skipping idempotency cleanup job — NODE_ENV is test'); + return undefined; + } + + const run = () => { + try { + deleteExpiredIdempotencyKeys(driver); + } catch (err) { + logger.error('Idempotency cleanup error:', (err as Error).message); + } + }; + + run(); + return setInterval(run, ONE_HOUR_MS); +} diff --git a/src/services/indexer.ts b/src/services/indexer.ts index 6870f105..56ce5889 100644 --- a/src/services/indexer.ts +++ b/src/services/indexer.ts @@ -28,11 +28,15 @@ export function normalizeEventId(contractId: string, ledger: number, txHash: str // Stub hook — replace with real logic as needed (e.g. metrics, alerting). // eslint-disable-next-line @typescript-eslint/no-unused-vars -function onBeforeInsert(_eventId: string): void { /* hook */ } +function onBeforeInsert(_eventId: string): void { + /* hook */ +} // Stub hook — called after a successful insert (INSERT OR IGNORE may be a no-op). // eslint-disable-next-line @typescript-eslint/no-unused-vars -function onAfterInsert(_eventId: string): void { /* hook */ } +function onAfterInsert(_eventId: string): void { + /* hook */ +} // ─── DB setup ──────────────────────────────────────────────────────────────── @@ -53,9 +57,8 @@ db.exec(` `); function getLastLedger(): number { - const row = db - .prepare('SELECT value FROM indexer_state WHERE key = ?') - .get('last_ledger') as { value: string } | undefined; + const row = db.prepare('SELECT value FROM indexer_state WHERE key = ?').get('last_ledger') as + { value: string } | undefined; return row ? parseInt(row.value, 10) : 0; } diff --git a/src/services/ipfs.ts b/src/services/ipfs.ts index 225877b1..aca9efc8 100644 --- a/src/services/ipfs.ts +++ b/src/services/ipfs.ts @@ -1,30 +1,3 @@ -// IPFS service (stub) -// Provides simple deterministic stubs for pinning JSON and retrieving CIDs. -// -// Pinata integration notes: -// - To integrate with Pinata, set PINATA_API_KEY and PINATA_SECRET_API_KEY in env. -// - Use Pinata's /pinning/pinJSONToIPFS endpoint with a POST containing the JSON body. -// - Optionally include metadata and options (pinPolicy) as described in Pinata docs. -// - For production, add retries, content-address verification, and monitor pin status. - -export async function pinJson(obj: unknown): Promise<{ cid: string }>{ - // Deterministic placeholder CID for tests. - // Replace with Pinata HTTP call when enabling real integration. - const jsonStr = typeof obj === 'string' ? obj : JSON.stringify(obj); - // Simple stable hash-like mock using string length and char codes. - const seed = String(jsonStr.length + (jsonStr.charCodeAt(0) || 0)); - const cid = `bafymock${seed}`; - return { cid }; -} - -export async function getCid(uriOrCid: string): Promise{ - // If an IPFS URI is provided like ipfs://, strip the prefix. - if (uriOrCid.startsWith('ipfs://')) return uriOrCid.replace('ipfs://',''); - // Return the input for deterministic behavior in tests. - return uriOrCid; -} - -export default { pinJson, getCid }; import axios from 'axios'; import FormData from 'form-data'; import config from '../config'; @@ -42,16 +15,18 @@ function headers() { /** Pin a JSON object to IPFS via Pinata. Returns the CID. */ export async function pinJson(body: object): Promise { + if (config.ipfsStubMode) { + return 'QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG'; + } const res = await axios.post(PINATA_PIN_URL, body, { headers: headers() }); return res.data.IpfsHash as string; } /** Pin a file buffer to IPFS via Pinata. Returns the CID. */ -export async function pinFile( - buffer: Buffer, - filename: string, - mimeType: string -): Promise { +export async function pinFile(buffer: Buffer, filename: string, mimeType: string): Promise { + if (config.ipfsStubMode) { + return 'QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG'; + } const form = new FormData(); form.append('file', buffer, { filename, contentType: mimeType }); const res = await axios.post(PINATA_FILE_URL, form, { @@ -75,5 +50,8 @@ export function gatewayUrl(cid: string): string { * Stub this function in tests to avoid real network calls. */ export async function checkHealth(): Promise { + if (config.ipfsStubMode) { + return; + } await axios.get(PINATA_TEST_URL, { headers: headers() }); } diff --git a/src/services/sep10.ts b/src/services/sep10.ts index 25fbc0f6..e0ea1a2c 100644 --- a/src/services/sep10.ts +++ b/src/services/sep10.ts @@ -12,7 +12,9 @@ * @param wallet - Stellar account that will sign the challenge * @returns object containing a mock XDR challenge and network passphrase */ -export async function createChallenge(wallet: string): Promise<{ challenge: string; networkPassphrase: string }>{ +export async function createChallenge( + wallet: string +): Promise<{ challenge: string; networkPassphrase: string }> { // Mock deterministic challenge value for tests const challenge = `CHALLENGE_FOR_${wallet}_MOCK`; const networkPassphrase = process.env.STELLAR_NETWORK_PASSPHRASE || 'Test SDF Network'; @@ -26,9 +28,12 @@ export async function createChallenge(wallet: string): Promise<{ challenge: stri * @param signature - client signature over the challenge * @returns verified account object when mock verification succeeds */ -export async function verifyChallenge(challenge: string, signature: string): Promise<{ account: string }>{ +export async function verifyChallenge( + challenge: string, + signature: string +): Promise<{ account: string }> { // Mock verification: accept a deterministic signature pattern in tests - if (signature === 'MOCK_VALID_SIGNATURE'){ + if (signature === 'MOCK_VALID_SIGNATURE') { // Extract wallet from challenge when possible const match = challenge.match(/^CHALLENGE_FOR_(.+?)_MOCK$/); const account = match ? match[1] : 'GMOCKACCOUNT'; @@ -53,7 +58,7 @@ import config from '../config'; const SERVER_KEYPAIR = Keypair.random(); // ephemeral; use a persisted key in production const CHALLENGE_TTL_SECONDS = 300; // 5 min to sign the challenge -const TOKEN_TTL_SECONDS = 86400; // 24 h JWT validity +const TOKEN_TTL_SECONDS = 86400; // 24 h JWT validity /** * Build a SEP-10 challenge transaction. @@ -63,8 +68,7 @@ export function buildChallenge(accountId: string): string { const serverAccount = new Account(SERVER_KEYPAIR.publicKey(), '-1'); const tx = new TransactionBuilder(serverAccount, { fee: BASE_FEE, - networkPassphrase: - config.network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET, + networkPassphrase: config.network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET, }) .addOperation( Operation.manageData({ @@ -86,8 +90,7 @@ export function buildChallenge(accountId: string): string { */ export function extractAccount(xdr: string): string | null { try { - const network = - config.network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET; + const network = config.network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET; const tx = new Transaction(xdr, network); return tx.operations[0].source ?? null; } catch { @@ -98,9 +101,11 @@ export function extractAccount(xdr: string): string | null { /** * Verify the client-signed challenge XDR and issue a JWT. */ -export function verifyAndIssueToken(xdr: string, role?: string): { token: string; account: string } { - const network = - config.network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET; +export function verifyAndIssueToken( + xdr: string, + role?: string +): { token: string; account: string } { + const network = config.network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET; const tx = new Transaction(xdr, network); diff --git a/src/services/stellar.ts b/src/services/stellar.ts index a3b6e3b8..40e4e694 100644 --- a/src/services/stellar.ts +++ b/src/services/stellar.ts @@ -1,41 +1,3 @@ -/** - * Stellar helper abstraction (mock) - * - * Placeholder implementations for signature verification, transaction building, - * and payment submission. Designed so controllers can import and use these - * helpers and later swap in real Stellar Horizon / SDK logic. - */ - -/** - * Verify a message signature against a public key. - * @returns true when signature matches a mock pattern - */ -export function verifySignature(message: string, signature: string, publicKey: string): boolean{ - // Simple deterministic mock used by tests. - return signature === `SIG_${publicKey}_${message}` || signature === 'MOCK_VALID_SIGNATURE'; -} - -/** - * Build a payment transaction XDR for submission. - * Returns a mock XDR string for tests and development. - */ -export async function buildTransaction(from: string, to: string, amount: string, memo?: string): Promise{ - // Mock XDR payload - return `MOCK_XDR from=${from} to=${to} amt=${amount} memo=${memo||''}`; -} - -/** - * Submit a payment XDR to the network (mock). - * Returns a success object with a fake transaction hash. - */ -export async function submitPayment(xdr: string): Promise<{ success: boolean; txHash?: string; error?: string }>{ - if (!xdr) return { success: false, error: 'empty xdr' }; - // deterministic mock hash - const txHash = `MOCK_TX_${Math.abs(xdr.length * 31).toString(16)}`; - return { success: true, txHash }; -} - -export default { verifySignature, buildTransaction, submitPayment }; import { SorobanRpc, TransactionBuilder, Networks, BASE_FEE } from '@stellar/stellar-sdk'; import config from '../config'; @@ -44,9 +6,7 @@ const server = new SorobanRpc.Server(config.sorobanRpcUrl); export { server }; export function networkPassphrase(): string { - return config.network === 'mainnet' - ? Networks.PUBLIC - : Networks.TESTNET; + return config.network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET; } /** @@ -67,7 +27,7 @@ export interface ContactPaymentResult { export class PaymentError extends Error { constructor( message: string, - public readonly code: 'INSUFFICIENT_FUNDS' | 'INVALID_ACCOUNT' | 'NETWORK_ERROR' | 'UNKNOWN', + public readonly code: 'INSUFFICIENT_FUNDS' | 'INVALID_ACCOUNT' | 'NETWORK_ERROR' | 'UNKNOWN' ) { super(message); this.name = 'PaymentError'; @@ -93,7 +53,7 @@ export async function stellarHealth(): Promise { */ export async function submitContactPayment( scoutWallet: string, - playerId: string, + playerId: string ): Promise { if (!scoutWallet || !playerId) { throw new PaymentError('Missing scoutWallet or playerId', 'INVALID_ACCOUNT'); @@ -104,16 +64,3 @@ export async function submitContactPayment( status: 'submitted', }; } - -/** - * Simple health probe for the Stellar/Soroban RPC. - * Returns true when the RPC responds; false otherwise. - */ -export async function stellarHealth(): Promise { - try { - await getLatestLedger(); - return true; - } catch { - return false; - } -} diff --git a/src/services/store.ts b/src/services/store.ts index 1c742c6c..5b026d7c 100644 --- a/src/services/store.ts +++ b/src/services/store.ts @@ -56,7 +56,7 @@ export function getMilestone(milestoneId: string): Milestone | undefined { } export function getPlayerMilestones(playerId: string): Milestone[] { - return Array.from(store.milestones.values()).filter(m => m.playerId === playerId); + return Array.from(store.milestones.values()).filter((m) => m.playerId === playerId); } export function addMilestone(milestone: Milestone): void { diff --git a/src/services/webhooks.ts b/src/services/webhooks.ts index 8d7d10ac..529a69c1 100644 --- a/src/services/webhooks.ts +++ b/src/services/webhooks.ts @@ -1,4 +1,4 @@ -import fetch from 'node-fetch'; +import axios from 'axios'; import config from '../config'; type WebhookRetryOptions = { @@ -27,13 +27,11 @@ export async function postWebhookWithRetry( for (let attempt = 1; attempt <= retries; attempt += 1) { try { - const response = await fetch(url, { - method: 'POST', + const response = await axios.post(url, payload, { headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), }); - if (response.ok) { + if (response.status >= 200 && response.status < 300) { return; } @@ -55,9 +53,13 @@ export async function dispatchEventWebhook(eventType: string, payload: unknown): if (!config.webhook.enabled || !config.webhook.url) { return; } - await postWebhookWithRetry(config.webhook.url, { eventType, payload }, { - retries: 3, - baseDelayMs: 500, - maxDelayMs: 5000, - }); + await postWebhookWithRetry( + config.webhook.url, + { eventType, payload }, + { + retries: 3, + baseDelayMs: 500, + maxDelayMs: 5000, + } + ); } diff --git a/src/types/index.ts b/src/types/index.ts index 29044d46..eeddc0a7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -67,7 +67,7 @@ export interface Milestone { playerId: string; milestoneType: MilestoneType; evidenceUri: string; // IPFS CID - validator: string; // Stellar address + validator: string; // Stellar address approved: boolean; createdAt: number; } diff --git a/src/utils/contract.ts b/src/utils/contract.ts index 1a6129aa..96d971c3 100644 --- a/src/utils/contract.ts +++ b/src/utils/contract.ts @@ -7,59 +7,291 @@ import { nativeToScVal, scValToNative, Keypair, + Transaction, + FeeBumpTransaction, } from '@stellar/stellar-sdk'; import { server, networkPassphrase } from '../services/stellar'; import config from '../config'; -/** - * Build, simulate, and submit a Soroban contract invocation. - * @param sourceKeypair - Keypair of the transaction source account - * @param method - Contract function name - * @param args - Array of xdr.ScVal arguments - */ -export async function invokeContract( - sourceKeypair: Keypair, - method: string, - args: xdr.ScVal[] -): Promise { - const account = await server.getAccount(sourceKeypair.publicKey()); - const contract = new Contract(config.contractId); - - const tx = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: networkPassphrase(), - }) - .addOperation(contract.call(method, ...args)) - .setTimeout(30) - .build(); - - // Simulate to get footprint + resource fee - const simResult = await server.simulateTransaction(tx); - if (SorobanRpc.Api.isSimulationError(simResult)) { - throw new Error(`Simulation failed: ${simResult.error}`); +export interface SorobanTransactionBuilderOptions { + server?: SorobanRpc.Server; + networkPassphrase?: string; +} + +export interface BuildOptions { + fee?: string; + timeout?: number; + sponsorKeypair?: Keypair; +} + +export interface SubmitOptions { + maxPollAttempts?: number; + pollIntervalMs?: number; + maxTryAgainRetries?: number; + tryAgainDelayMs?: number; + autoRefreshSequenceOnConflict?: boolean; +} + +export interface SimulationResult { + simResult: SorobanRpc.Api.SimulateTransactionResponse; + preparedTx: Transaction; + authEntries?: xdr.SorobanAuthorizationEntry[]; +} + +export class SorobanTransactionBuilder { + private rpcServer: SorobanRpc.Server; + private passphrase: string; + + constructor(options?: SorobanTransactionBuilderOptions) { + this.rpcServer = options?.server || server; + this.passphrase = options?.networkPassphrase || networkPassphrase(); } - const preparedTx = SorobanRpc.assembleTransaction(tx, simResult).build(); - preparedTx.sign(sourceKeypair); + /** + * Fetches account sequence and creates an un-simulated Soroban contract call transaction. + */ + async buildContractCall( + contractId: string, + functionName: string, + args: xdr.ScVal[] = [], + sourcePublicKey: string, + options?: BuildOptions + ): Promise { + let account; + try { + account = await this.rpcServer.getAccount(sourcePublicKey); + } catch (err: unknown) { + throw new Error(`Failed to fetch account sequence for ${sourcePublicKey}: ${(err as Error).message}`); + } + + const contract = new Contract(contractId); + const tx = new TransactionBuilder(account, { + fee: options?.fee || BASE_FEE, + networkPassphrase: this.passphrase, + }) + .addOperation(contract.call(functionName, ...args)) + .setTimeout(options?.timeout ?? 30) + .build(); - const sendResult = await server.sendTransaction(preparedTx); - if (sendResult.status === 'ERROR') { - throw new Error(`Submit failed: ${sendResult.errorResult}`); + return tx; } - // Poll for confirmation - let getResult = await server.getTransaction(sendResult.hash); - while (getResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND) { - await new Promise((r) => setTimeout(r, 1000)); - getResult = await server.getTransaction(sendResult.hash); + /** + * Simulates the transaction and assembles resource footprint and fee estimates. + */ + async simulate(tx: Transaction): Promise { + const simResult = await this.rpcServer.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simResult)) { + throw new Error(`Simulation failed: ${simResult.error}`); + } + + const preparedTx = SorobanRpc.assembleTransaction(tx, simResult).build(); + + let authEntries: xdr.SorobanAuthorizationEntry[] | undefined; + const simWithResults = simResult as unknown as { results?: Array<{ auth?: xdr.SorobanAuthorizationEntry[] }>; result?: { auth?: xdr.SorobanAuthorizationEntry[] } }; + if (simWithResults.results && simWithResults.results.length > 0 && simWithResults.results[0].auth) { + authEntries = simWithResults.results[0].auth; + } else if (simWithResults.result && simWithResults.result.auth) { + authEntries = simWithResults.result.auth; + } + + return { + simResult, + preparedTx, + authEntries, + }; } - if (getResult.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { - throw new Error('Transaction failed on-chain'); + /** + * Signs transaction or fee bump transaction with provided Keypair or returns signed pre-signed XDR. + */ + sign( + tx: Transaction | FeeBumpTransaction, + signerKeypair?: Keypair, + sponsorKeypair?: Keypair + ): Transaction | FeeBumpTransaction { + let targetTx = tx; + + if (sponsorKeypair && !(targetTx instanceof FeeBumpTransaction)) { + targetTx = TransactionBuilder.buildFeeBumpTransaction( + sponsorKeypair.publicKey(), + BASE_FEE, + targetTx, + this.passphrase + ); + targetTx.sign(sponsorKeypair); + } + + if (signerKeypair) { + targetTx.sign(signerKeypair); + } + + return targetTx; } - const successResult = getResult as SorobanRpc.Api.GetSuccessfulTransactionResponse; - return successResult.returnValue ? scValToNative(successResult.returnValue) : null; + /** + * Submits a transaction to Soroban RPC, handling TRY_AGAIN_LATER retries, + * sequence number conflict auto-refresh/retry, and polling for tx status. + */ + async submit( + signedTx: Transaction | FeeBumpTransaction, + submitOptions?: SubmitOptions, + contextInfo?: { + contractId?: string; + functionName?: string; + args?: xdr.ScVal[]; + sourcePublicKey?: string; + signerKeypair?: Keypair; + sponsorKeypair?: Keypair; + buildOptions?: BuildOptions; + } + ): Promise { + const maxTryAgainRetries = submitOptions?.maxTryAgainRetries ?? 3; + const tryAgainDelayMs = submitOptions?.tryAgainDelayMs ?? 2000; + const maxPollAttempts = submitOptions?.maxPollAttempts ?? 10; + const pollIntervalMs = submitOptions?.pollIntervalMs ?? 1000; + const autoRefreshSequenceOnConflict = submitOptions?.autoRefreshSequenceOnConflict ?? true; + + let currentTx = signedTx; + let sendResult: SorobanRpc.Api.SendTransactionResponse | undefined; + + let sendAttempts = 0; + while (sendAttempts <= maxTryAgainRetries) { + sendAttempts++; + try { + sendResult = await this.rpcServer.sendTransaction(currentTx); + } catch (err: unknown) { + const errObj = err as { response?: { status?: number; data?: unknown }; message?: string }; + const isSeqConflict = + errObj?.response?.status === 400 || + (errObj?.message && errObj.message.includes('400')); + + if (isSeqConflict && autoRefreshSequenceOnConflict && contextInfo?.sourcePublicKey && contextInfo?.contractId && contextInfo?.functionName) { + const newTx = await this.buildContractCall( + contextInfo.contractId, + contextInfo.functionName, + contextInfo.args || [], + contextInfo.sourcePublicKey, + contextInfo.buildOptions + ); + const sim = await this.simulate(newTx); + currentTx = this.sign(sim.preparedTx, contextInfo.signerKeypair, contextInfo.sponsorKeypair); + continue; + } + throw err; + } + + if (sendResult.status === 'TRY_AGAIN_LATER') { + if (sendAttempts > maxTryAgainRetries) { + throw new Error(`Submit failed with TRY_AGAIN_LATER after ${maxTryAgainRetries} retries`); + } + await new Promise((resolve) => setTimeout(resolve, tryAgainDelayMs)); + continue; + } + + if (sendResult.status === 'ERROR') { + const errorResultStr = JSON.stringify(sendResult.errorResult || ''); + if ( + autoRefreshSequenceOnConflict && + (errorResultStr.includes('txBAD_SEQ') || errorResultStr.includes('400')) && + contextInfo?.sourcePublicKey && + contextInfo?.contractId && + contextInfo?.functionName + ) { + const newTx = await this.buildContractCall( + contextInfo.contractId, + contextInfo.functionName, + contextInfo.args || [], + contextInfo.sourcePublicKey, + contextInfo.buildOptions + ); + const sim = await this.simulate(newTx); + currentTx = this.sign(sim.preparedTx, contextInfo.signerKeypair, contextInfo.sponsorKeypair); + continue; + } + throw new Error(`Submit failed: ${JSON.stringify(sendResult.errorResult)}`); + } + + break; + } + + if (!sendResult || !sendResult.hash) { + throw new Error('Submit failed: invalid response from sendTransaction'); + } + + const hash = sendResult.hash; + + let pollAttempts = 0; + while (pollAttempts < maxPollAttempts) { + pollAttempts++; + const getResult = await this.rpcServer.getTransaction(hash); + + if (getResult.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + return getResult as SorobanRpc.Api.GetSuccessfulTransactionResponse; + } + + if (getResult.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { + throw new Error(`Transaction failed on-chain: ${JSON.stringify((getResult as { resultXdr?: unknown }).resultXdr || getResult)}`); + } + + if (pollAttempts < maxPollAttempts) { + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + } + + throw new Error(`Transaction polling timed out after ${maxPollAttempts} attempts`); + } + + /** + * End-to-end execution helper: build -> simulate -> sign -> submit. + */ + async executeContractCall( + contractId: string, + functionName: string, + args: xdr.ScVal[] = [], + signerKeypair: Keypair, + options?: { + buildOptions?: BuildOptions; + submitOptions?: SubmitOptions; + sponsorKeypair?: Keypair; + } + ): Promise { + const sourcePublicKey = signerKeypair.publicKey(); + const initialTx = await this.buildContractCall( + contractId, + functionName, + args, + sourcePublicKey, + options?.buildOptions + ); + + const simulation = await this.simulate(initialTx); + const signedTx = this.sign(simulation.preparedTx, signerKeypair, options?.sponsorKeypair); + + const result = await this.submit(signedTx, options?.submitOptions, { + contractId, + functionName, + args, + sourcePublicKey, + signerKeypair, + sponsorKeypair: options?.sponsorKeypair, + buildOptions: options?.buildOptions, + }); + + return result.returnValue ? scValToNative(result.returnValue) : null; + } +} + +/** + * Convenience backward-compatible invokeContract implementation utilizing SorobanTransactionBuilder. + */ +export async function invokeContract( + sourceKeypair: Keypair, + method: string, + args: xdr.ScVal[] = [] +): Promise { + const builder = new SorobanTransactionBuilder(); + return builder.executeContractCall(config.contractId, method, args, sourceKeypair); } /** Convenience: convert a plain string to ScVal */ diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 274d750e..3f401ec1 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -8,7 +8,7 @@ function shouldLog(level: keyof typeof LEVELS): boolean { export const logger = { debug: (...args: unknown[]) => shouldLog('debug') && console.debug('[debug]', ...args), - info: (...args: unknown[]) => shouldLog('info') && console.info('[info]', ...args), - warn: (...args: unknown[]) => shouldLog('warn') && console.warn('[warn]', ...args), + info: (...args: unknown[]) => shouldLog('info') && console.info('[info]', ...args), + warn: (...args: unknown[]) => shouldLog('warn') && console.warn('[warn]', ...args), error: (...args: unknown[]) => shouldLog('error') && console.error('[error]', ...args), }; diff --git a/tests/db/postgresDriverClose.test.ts b/tests/db/postgresDriverClose.test.ts new file mode 100644 index 00000000..d4f811e6 --- /dev/null +++ b/tests/db/postgresDriverClose.test.ts @@ -0,0 +1,62 @@ +import { register } from '../../src/middleware/metrics'; + +jest.mock('pg', () => { + const MockPool = jest.fn().mockImplementation(() => ({ + totalCount: 0, + idleCount: 0, + on: jest.fn(), + connect: jest.fn().mockResolvedValue({ + query: jest.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }), + release: jest.fn(), + }), + end: jest.fn().mockResolvedValue(undefined), + })); + return { Pool: MockPool }; +}); + +import { getPool, closePool, poolHealth } from '../../src/db/postgres-driver'; + +beforeEach(async () => { + await closePool(); + register.resetMetrics(); +}); + +afterAll(async () => { + await closePool(); +}); + +describe('closePool', () => { + it('terminates the pool without throwing', async () => { + getPool(); + await expect(closePool()).resolves.toBeUndefined(); + }); + + it('is idempotent — calling closePool twice does not throw', async () => { + getPool(); + await closePool(); + await expect(closePool()).resolves.toBeUndefined(); + }); + + it('creates a fresh pool after close', async () => { + const pool1 = getPool(); + await closePool(); + const pool2 = getPool(); + expect(pool1).not.toBe(pool2); + }); +}); + +describe('poolHealth', () => { + it('resolves when the database is reachable', async () => { + await expect(poolHealth()).resolves.toBeUndefined(); + }); + + it('releases the client after success', async () => { + const pool = getPool(); + const client = await pool.connect(); + const releaseSpy = jest.spyOn(client, 'release'); + client.release(); + + await poolHealth(); + expect(releaseSpy).toHaveBeenCalled(); + }); +}); diff --git a/tests/db/postgresDriverSsl.test.ts b/tests/db/postgresDriverSsl.test.ts new file mode 100644 index 00000000..6334c499 --- /dev/null +++ b/tests/db/postgresDriverSsl.test.ts @@ -0,0 +1,72 @@ +jest.mock('pg', () => { + const MockPool = jest.fn().mockImplementation((_config) => ({ + _config, + totalCount: 0, + idleCount: 0, + on: jest.fn(), + connect: jest.fn().mockResolvedValue({ + query: jest.fn().mockResolvedValue({ rows: [{ '?column?': 1 }] }), + release: jest.fn(), + }), + end: jest.fn().mockResolvedValue(undefined), + })); + return { Pool: MockPool }; +}); + +import { closePool } from '../../src/db/postgres-driver'; + +beforeEach(async () => { + await closePool(); +}); + +afterAll(async () => { + await closePool(); +}); + +function getConfig(sslEnv: string) { + const prev = process.env.DATABASE_SSL; + process.env.DATABASE_SSL = sslEnv; + + // Re-import to pick up the new env var + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require('../../src/config'); + const cfg = mod.default; + + process.env.DATABASE_SSL = prev ?? 'false'; + return cfg; +} + +describe('SSL configuration', () => { + it('DATABASE_SSL=true sets rejectUnauthorized: true', () => { + const cfg = getConfig('true'); + expect(cfg.databaseSsl).toEqual({ rejectUnauthorized: true }); + }); + + it('DATABASE_SSL=no-verify sets rejectUnauthorized: false', () => { + const cfg = getConfig('no-verify'); + expect(cfg.databaseSsl).toEqual({ rejectUnauthorized: false }); + }); + + it('DATABASE_SSL=false disables SSL', () => { + const cfg = getConfig('false'); + expect(cfg.databaseSsl).toBe(false); + }); + + it('DATABASE_SSL unset defaults to false', () => { + const cfg = getConfig(''); + expect(cfg.databaseSsl).toBe(false); + }); + + it('Pool receives ssl config from environment', async () => { + const prev = process.env.DATABASE_SSL; + process.env.DATABASE_SSL = 'true'; + await closePool(); + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { getPool: freshGetPool } = require('../../src/db/postgres-driver'); + const pool = freshGetPool(); + expect(pool._config.ssl).toEqual({ rejectUnauthorized: true }); + process.env.DATABASE_SSL = prev ?? 'false'; + }); +}); diff --git a/tests/middleware/correlationId.test.ts b/tests/middleware/correlationId.test.ts index 8a955faf..221825d3 100644 --- a/tests/middleware/correlationId.test.ts +++ b/tests/middleware/correlationId.test.ts @@ -7,7 +7,12 @@ function makeReq(headers: Record = {}): Request { function makeRes(): { headers: Record; setHeader: jest.Mock } { const headers: Record = {}; - return { headers, setHeader: jest.fn((k, v) => { headers[k] = v; }) }; + return { + headers, + setHeader: jest.fn((k, v) => { + headers[k] = v; + }), + }; } describe('correlationId middleware', () => { diff --git a/tests/middleware/idempotency.test.ts b/tests/middleware/idempotency.test.ts new file mode 100644 index 00000000..d7b17a89 --- /dev/null +++ b/tests/middleware/idempotency.test.ts @@ -0,0 +1,42 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +describe('idempotency cleanup', () => { + let dbPath: string; + + beforeEach(() => { + dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'idempotency-')), 'db.sqlite'); + process.env.DB_PATH = dbPath; + jest.resetModules(); + }); + + afterEach(() => { + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + }); + + it('removes expired rows while preserving unexpired ones', () => { + const { + getIdempotencyDatabase, + purgeExpiredIdempotencyKeys, + } = require('../../src/middleware/idempotency'); + const db = getIdempotencyDatabase(); + + db.prepare( + `INSERT INTO idempotency_keys (key, expires_at, request_hash, method, path, status_code, response_body, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run('expired-key', 10, 'hash-1', 'POST', '/api/players', 200, '{"ok":true}', 1); + + db.prepare( + `INSERT INTO idempotency_keys (key, expires_at, request_hash, method, path, status_code, response_body, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run('active-key', 1000, 'hash-2', 'POST', '/api/players', 200, '{"ok":true}', 1); + + const deleted = purgeExpiredIdempotencyKeys(100); + + expect(deleted).toBe(1); + expect(db.prepare('SELECT key FROM idempotency_keys ORDER BY key').all()).toEqual([ + { key: 'active-key' }, + ]); + }); +}); diff --git a/tests/middleware/validate.test.ts b/tests/middleware/validate.test.ts index f391bb14..99d8e48f 100644 --- a/tests/middleware/validate.test.ts +++ b/tests/middleware/validate.test.ts @@ -45,7 +45,12 @@ describe('validateBody — player registerSchema', () => { const middleware = validateBody(registerSchema); it('calls next() for a valid body', () => { - const req = makeBodyReq({ wallet: 'G'.repeat(56), position: 'striker', region: 'Africa', metadata: {} }); + const req = makeBodyReq({ + wallet: 'G'.repeat(56), + position: 'striker', + region: 'Africa', + metadata: {}, + }); const res = makeRes(); const next = jest.fn() as NextFunction; middleware(req, res, next); @@ -72,7 +77,12 @@ describe('validateBody — player registerSchema', () => { }); it('returns 400 when wallet is too short', () => { - const req = makeBodyReq({ wallet: 'GSHORT', position: 'striker', region: 'Africa', metadata: {} }); + const req = makeBodyReq({ + wallet: 'GSHORT', + position: 'striker', + region: 'Africa', + metadata: {}, + }); const res = makeRes(); const next = jest.fn() as NextFunction; middleware(req, res, next); @@ -81,7 +91,12 @@ describe('validateBody — player registerSchema', () => { }); it('returns 400 when position is empty string', () => { - const req = makeBodyReq({ wallet: 'G'.repeat(56), position: '', region: 'Africa', metadata: {} }); + const req = makeBodyReq({ + wallet: 'G'.repeat(56), + position: '', + region: 'Africa', + metadata: {}, + }); const res = makeRes(); const next = jest.fn() as NextFunction; middleware(req, res, next); @@ -107,7 +122,11 @@ describe('validateBody — milestoneSchema', () => { const middleware = validateBody(milestoneSchema); it('calls next() for a valid milestone body', () => { - const req = makeBodyReq({ playerId: 'player-1', milestoneType: 'performance', evidenceUri: 'ipfs://Qm123' }); + const req = makeBodyReq({ + playerId: 'player-1', + milestoneType: 'performance', + evidenceUri: 'ipfs://Qm123', + }); const res = makeRes(); const next = jest.fn() as NextFunction; middleware(req, res, next); diff --git a/tests/routes/admin.test.ts b/tests/routes/admin.test.ts index 02484aec..6e0f03e3 100644 --- a/tests/routes/admin.test.ts +++ b/tests/routes/admin.test.ts @@ -7,9 +7,7 @@ async function getToken(role: string): Promise { const challengeRes = await request(app).get(`/auth/challenge?account=${kp.publicKey()}`); const tx = new Transaction(challengeRes.body.challenge, Networks.TESTNET); tx.sign(kp); - const tokenRes = await request(app) - .post('/auth/token') - .send({ transaction: tx.toXDR(), role }); + const tokenRes = await request(app).post('/auth/token').send({ transaction: tx.toXDR(), role }); return tokenRes.body.token; } diff --git a/tests/routes/adminExport.test.ts b/tests/routes/adminExport.test.ts index 26a641b0..06af472f 100644 --- a/tests/routes/adminExport.test.ts +++ b/tests/routes/adminExport.test.ts @@ -6,13 +6,21 @@ function makeRes() { let body: string | undefined; let statusCode = 200; const res = { - setHeader: (name: string, value: string) => { headers[name.toLowerCase()] = value; }, + setHeader: (name: string, value: string) => { + headers[name.toLowerCase()] = value; + }, status: jest.fn().mockReturnThis(), - send: jest.fn((data: string) => { body = data; return res; }), + send: jest.fn((data: string) => { + body = data; + return res; + }), _headers: headers, _body: () => body, } as unknown as Response & { _headers: Record; _body: () => string | undefined }; - (res.status as jest.Mock).mockImplementation((code: number) => { statusCode = code; return res; }); + (res.status as jest.Mock).mockImplementation((code: number) => { + statusCode = code; + return res; + }); return { res, headers, getBody: () => body, getStatus: () => statusCode }; } diff --git a/tests/routes/api.test.ts b/tests/routes/api.test.ts index 208b03c7..1079fc1a 100644 --- a/tests/routes/api.test.ts +++ b/tests/routes/api.test.ts @@ -53,9 +53,7 @@ describe('POST /api/players/register', () => { }); it('accepts registration payloads with valid metadataUri', async () => { - const res = await request(app) - .post('/api/players/register') - .send(validPlayer); + const res = await request(app).post('/api/players/register').send(validPlayer); expect(res.status).toBe(201); expect(res.body.success).toBe(true); @@ -112,9 +110,7 @@ describe('POST /auth/token', () => { const tx = new Transaction(challengeRes.body.challenge, Networks.TESTNET); tx.sign(clientKeypair); - const res = await request(app) - .post('/auth/token') - .send({ transaction: tx.toXDR() }); + const res = await request(app).post('/auth/token').send({ transaction: tx.toXDR() }); expect(res.status).toBe(200); expect(typeof res.body.token).toBe('string'); @@ -239,25 +235,19 @@ describe('GET /api/admin/events', () => { it('returns 403 when authenticated as non-admin role', async () => { const token = await getPlayerToken(); - const res = await request(app) - .get('/api/admin/events') - .set('Authorization', `Bearer ${token}`); + const res = await request(app).get('/api/admin/events').set('Authorization', `Bearer ${token}`); expect(res.status).toBe(403); }); it('returns 403 when authenticated as validator role', async () => { const token = await getValidatorToken(); - const res = await request(app) - .get('/api/admin/events') - .set('Authorization', `Bearer ${token}`); + const res = await request(app).get('/api/admin/events').set('Authorization', `Bearer ${token}`); expect(res.status).toBe(403); }); it('returns event list for authenticated admin', async () => { const token = await getAdminToken(); - const res = await request(app) - .get('/api/admin/events') - .set('Authorization', `Bearer ${token}`); + const res = await request(app).get('/api/admin/events').set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(Array.isArray(res.body.data)).toBe(true); @@ -267,17 +257,13 @@ describe('GET /api/admin/events', () => { describe('GET /api/admin/fees', () => { it('returns 403 when authenticated as non-admin role', async () => { const token = await getValidatorToken(); - const res = await request(app) - .get('/api/admin/fees') - .set('Authorization', `Bearer ${token}`); + const res = await request(app).get('/api/admin/fees').set('Authorization', `Bearer ${token}`); expect(res.status).toBe(403); }); it('returns fee list for authenticated admin', async () => { const token = await getAdminToken(); - const res = await request(app) - .get('/api/admin/fees') - .set('Authorization', `Bearer ${token}`); + const res = await request(app).get('/api/admin/fees').set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(Array.isArray(res.body.data)).toBe(true); diff --git a/tests/routes/health.test.ts b/tests/routes/health.test.ts index 1b421f80..8c8c4205 100644 --- a/tests/routes/health.test.ts +++ b/tests/routes/health.test.ts @@ -5,6 +5,8 @@ // Stub the ipfs service before app is imported so the /ready handler // uses the mock implementation throughout these tests. +process.env.STELLAR_HEALTH_CHECK = 'false'; + jest.mock('../../src/services/ipfs', () => ({ pinJson: jest.fn(), pinFile: jest.fn(), diff --git a/tests/routes/payments.test.ts b/tests/routes/payments.test.ts index 0edb7aaf..ddfe7d66 100644 --- a/tests/routes/payments.test.ts +++ b/tests/routes/payments.test.ts @@ -7,9 +7,7 @@ async function getToken(role = 'scout'): Promise { const challengeRes = await request(app).get(`/auth/challenge?account=${kp.publicKey()}`); const tx = new Transaction(challengeRes.body.challenge, Networks.TESTNET); tx.sign(kp); - const tokenRes = await request(app) - .post('/auth/token') - .send({ transaction: tx.toXDR(), role }); + const tokenRes = await request(app).post('/auth/token').send({ transaction: tx.toXDR(), role }); return tokenRes.body.token; } diff --git a/tests/routes/scout.test.ts b/tests/routes/scout.test.ts index 53b74b55..c225803c 100644 --- a/tests/routes/scout.test.ts +++ b/tests/routes/scout.test.ts @@ -18,7 +18,7 @@ function makeToken(wallet: string, role = 'scout'): string { } const WALLET = 'GSCOUTWALLET1AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; -const OTHER = 'GOTHERWALLET2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const OTHER = 'GOTHERWALLET2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; beforeEach(() => { mockGetEvents.mockReset(); diff --git a/tests/services/idempotencyCleanup.test.ts b/tests/services/idempotencyCleanup.test.ts new file mode 100644 index 00000000..dd2048aa --- /dev/null +++ b/tests/services/idempotencyCleanup.test.ts @@ -0,0 +1,88 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +describe('idempotency cleanup job', () => { + let dbPath: string; + + beforeEach(() => { + dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'idempotency-cleanup-')), 'db.sqlite'); + process.env.DB_PATH = dbPath; + jest.resetModules(); + }); + + afterEach(() => { + delete process.env.NODE_ENV; + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + }); + + it('deletes only keys where created_at is older than 24 hours', () => { + const { getIdempotencyDatabase, cleanupDriver } = require('../../src/middleware/idempotency'); + const { deleteExpiredIdempotencyKeys } = require('../../src/services/idempotencyCleanup'); + const { idempotencyKeysDeletedTotal } = require('../../src/middleware/metrics'); + const incSpy = jest.spyOn(idempotencyKeysDeletedTotal, 'inc'); + + const db = getIdempotencyDatabase(); + const now = 1_000_000_000; + const oneDay = 24 * 60 * 60; + + db.prepare( + `INSERT INTO idempotency_keys (key, expires_at, request_hash, method, path, status_code, response_body, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run('old-key', now + oneDay, 'hash-1', 'POST', '/api/test', 200, '{}', now - 2 * oneDay); + + db.prepare( + `INSERT INTO idempotency_keys (key, expires_at, request_hash, method, path, status_code, response_body, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run('recent-key', now + oneDay, 'hash-2', 'POST', '/api/test', 200, '{}', now - 12 * 60 * 60); + + const deleted = deleteExpiredIdempotencyKeys(cleanupDriver, now); + + expect(deleted).toBe(1); + expect(db.prepare('SELECT key FROM idempotency_keys ORDER BY key').all()).toEqual([ + { key: 'recent-key' }, + ]); + expect(incSpy).toHaveBeenCalledWith(1); + }); + + it('does not delete any rows when all keys are recent', () => { + const { getIdempotencyDatabase, cleanupDriver } = require('../../src/middleware/idempotency'); + const { deleteExpiredIdempotencyKeys } = require('../../src/services/idempotencyCleanup'); + const { idempotencyKeysDeletedTotal } = require('../../src/middleware/metrics'); + const incSpy = jest.spyOn(idempotencyKeysDeletedTotal, 'inc'); + + const db = getIdempotencyDatabase(); + const now = 1_000_000_000; + const oneDay = 24 * 60 * 60; + + db.prepare( + `INSERT INTO idempotency_keys (key, expires_at, request_hash, method, path, status_code, response_body, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run('recent-a', now + oneDay, 'hash-1', 'POST', '/api/test', 200, '{}', now - 10 * 60 * 60); + + db.prepare( + `INSERT INTO idempotency_keys (key, expires_at, request_hash, method, path, status_code, response_body, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run('recent-b', now + oneDay, 'hash-2', 'POST', '/api/test', 200, '{}', now - 5 * 60 * 60); + + const deleted = deleteExpiredIdempotencyKeys(cleanupDriver, now); + + expect(deleted).toBe(0); + expect(db.prepare('SELECT key FROM idempotency_keys ORDER BY key').all()).toEqual([ + { key: 'recent-a' }, + { key: 'recent-b' }, + ]); + expect(incSpy).not.toHaveBeenCalled(); + }); + + it('skips the cleanup job in test environment', () => { + process.env.NODE_ENV = 'test'; + jest.resetModules(); + + const { startIdempotencyCleanupJob } = require('../../src/services/idempotencyCleanup'); + const { cleanupDriver } = require('../../src/middleware/idempotency'); + + const result = startIdempotencyCleanupJob(cleanupDriver); + expect(result).toBeUndefined(); + }); +}); diff --git a/tests/services/webhooks.test.ts b/tests/services/webhooks.test.ts index 1d2a1493..d214e360 100644 --- a/tests/services/webhooks.test.ts +++ b/tests/services/webhooks.test.ts @@ -1,9 +1,9 @@ -import fetch from 'node-fetch'; +import axios from 'axios'; import { postWebhookWithRetry } from '../../src/services/webhooks'; -jest.mock('node-fetch', () => jest.fn()); +jest.mock('axios'); -const mockedFetch = fetch as jest.MockedFunction; +const mockedAxios = axios as jest.Mocked; describe('postWebhookWithRetry', () => { beforeEach(() => { @@ -11,30 +11,40 @@ describe('postWebhookWithRetry', () => { }); it('returns successfully when the first request succeeds', async () => { - mockedFetch.mockResolvedValue({ ok: true, status: 200 } as any); + mockedAxios.post.mockResolvedValue({ status: 200 } as any); - await expect(postWebhookWithRetry('https://example.com', { eventType: 'test' })).resolves.toBeUndefined(); - expect(mockedFetch).toHaveBeenCalledTimes(1); + await expect( + postWebhookWithRetry('https://example.com', { eventType: 'test' }) + ).resolves.toBeUndefined(); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); }); it('retries on an initial failure and succeeds on a later attempt', async () => { - mockedFetch.mockRejectedValueOnce(new Error('network fail')); - mockedFetch.mockResolvedValue({ ok: true, status: 200 } as any); + mockedAxios.post.mockRejectedValueOnce(new Error('network fail')); + mockedAxios.post.mockResolvedValue({ status: 200 } as any); await expect( - postWebhookWithRetry('https://example.com', { eventType: 'test' }, { retries: 3, baseDelayMs: 1, maxDelayMs: 2 }) + postWebhookWithRetry( + 'https://example.com', + { eventType: 'test' }, + { retries: 3, baseDelayMs: 1, maxDelayMs: 2 } + ) ).resolves.toBeUndefined(); - expect(mockedFetch).toHaveBeenCalledTimes(2); + expect(mockedAxios.post).toHaveBeenCalledTimes(2); }); it('throws after all retries fail', async () => { - mockedFetch.mockRejectedValue(new Error('network down')); + mockedAxios.post.mockRejectedValue(new Error('network down')); await expect( - postWebhookWithRetry('https://example.com', { eventType: 'test' }, { retries: 2, baseDelayMs: 1, maxDelayMs: 2 }) + postWebhookWithRetry( + 'https://example.com', + { eventType: 'test' }, + { retries: 2, baseDelayMs: 1, maxDelayMs: 2 } + ) ).rejects.toThrow('network down'); - expect(mockedFetch).toHaveBeenCalledTimes(2); + expect(mockedAxios.post).toHaveBeenCalledTimes(2); }); }); diff --git a/tests/setup.ts b/tests/setup.ts index 7c291d04..9d918352 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,7 +1,14 @@ // Set required env vars before any module is loaded in tests -process.env.CONTRACT_ID = process.env.CONTRACT_ID ?? 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; -process.env.JWT_SECRET = process.env.JWT_SECRET ?? 'test-secret'; -process.env.DB_PATH = process.env.DB_PATH ?? ':memory:'; +process.env.CONTRACT_ID = + process.env.CONTRACT_ID ?? 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; +process.env.JWT_SECRET = process.env.JWT_SECRET ?? 'test-secret'; +process.env.DB_PATH = process.env.DB_PATH ?? ':memory:'; // Use port 0 so each test file's server instance binds to a random // available port, preventing EADDRINUSE conflicts across test suites. -process.env.PORT = process.env.PORT ?? '0'; +process.env.PORT = process.env.PORT ?? '0'; +// PostgreSQL defaults for tests — points to a non-existent local instance +// so tests that mock pg won't attempt real connections. +process.env.DATABASE_URL = process.env.DATABASE_URL ?? 'postgresql://localhost:5432/scoutoff_test'; +process.env.DATABASE_SSL = process.env.DATABASE_SSL ?? 'false'; +process.env.DB_POOL_MIN = process.env.DB_POOL_MIN ?? '0'; +process.env.DB_POOL_MAX = process.env.DB_POOL_MAX ?? '2'; diff --git a/tests/utils/contract.test.ts b/tests/utils/contract.test.ts new file mode 100644 index 00000000..ad3be4c5 --- /dev/null +++ b/tests/utils/contract.test.ts @@ -0,0 +1,302 @@ +import { + Keypair, + SorobanRpc, + Networks, + Account, + nativeToScVal, + Transaction, + FeeBumpTransaction, + xdr, +} from '@stellar/stellar-sdk'; +import { SorobanTransactionBuilder, strVal } from '../../src/utils/contract'; + +describe('SorobanTransactionBuilder', () => { + const dummyContractId = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM'; + const dummyKeypair = Keypair.random(); + const sponsorKeypair = Keypair.random(); + const networkPassphrase = Networks.TESTNET; + + let mockRpcServer: jest.Mocked; + let builder: SorobanTransactionBuilder; + + beforeEach(() => { + mockRpcServer = { + getAccount: jest.fn(), + simulateTransaction: jest.fn(), + sendTransaction: jest.fn(), + getTransaction: jest.fn(), + } as unknown as jest.Mocked; + + builder = new SorobanTransactionBuilder({ + server: mockRpcServer, + networkPassphrase, + }); + }); + + describe('buildContractCall', () => { + it('fetches account sequence and builds transaction', async () => { + const mockAccount = new Account(dummyKeypair.publicKey(), '100'); + mockRpcServer.getAccount.mockResolvedValue(mockAccount as unknown as Account); + + const tx = await builder.buildContractCall( + dummyContractId, + 'register_player', + [strVal('test')], + dummyKeypair.publicKey() + ); + + expect(mockRpcServer.getAccount).toHaveBeenCalledWith(dummyKeypair.publicKey()); + expect(tx).toBeInstanceOf(Transaction); + expect(tx.sequence).toBe('101'); + }); + + it('throws error if account fetch fails', async () => { + mockRpcServer.getAccount.mockRejectedValue(new Error('Account not found')); + + await expect( + builder.buildContractCall(dummyContractId, 'test', [], dummyKeypair.publicKey()) + ).rejects.toThrow('Failed to fetch account sequence'); + }); + }); + + describe('simulate', () => { + it('simulates transaction and returns assembled prepared tx and auth entries', async () => { + const mockAccount = new Account(dummyKeypair.publicKey(), '100'); + mockRpcServer.getAccount.mockResolvedValue(mockAccount as unknown as Account); + + const tx = await builder.buildContractCall( + dummyContractId, + 'health', + [], + dummyKeypair.publicKey() + ); + + const sorobanData = new xdr.SorobanTransactionData({ + resources: new xdr.SorobanResources({ + footprint: new xdr.LedgerFootprint({ + readOnly: [], + readWrite: [], + }), + instructions: 100, + readBytes: 100, + writeBytes: 100, + }), + resourceFee: new xdr.Int64(1000), + ext: xdr.ExtensionPoint.fromXDR(Buffer.from([0, 0, 0, 0])), + }); + + const mockAuthEntry = new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsSourceAccount(), + rootInvocation: new xdr.SorobanAuthorizedInvocation({ + function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.InvokeContractArgs({ + contractAddress: xdr.ScAddress.scAddressTypeContract( + Buffer.from('0000000000000000000000000000000000000000000000000000000000000000', 'hex') + ), + functionName: 'health', + args: [], + }) + ), + subInvocations: [], + }), + }); + + const mockSimResult = { + minResourceFee: '1000', + results: [ + { + auth: [mockAuthEntry.toXDR('base64')], + retval: nativeToScVal('ok'), + }, + ], + transactionData: sorobanData.toXDR('base64'), + }; + + mockRpcServer.simulateTransaction.mockResolvedValue(mockSimResult as unknown as SorobanRpc.Api.SimulateTransactionResponse); + + const result = await builder.simulate(tx); + expect(result.preparedTx).toBeInstanceOf(Transaction); + expect(result.authEntries).toBeDefined(); + }); + + it('throws error if simulation returns an error', async () => { + const mockAccount = new Account(dummyKeypair.publicKey(), '100'); + mockRpcServer.getAccount.mockResolvedValue(mockAccount as unknown as Account); + + const tx = await builder.buildContractCall( + dummyContractId, + 'health', + [], + dummyKeypair.publicKey() + ); + + mockRpcServer.simulateTransaction.mockResolvedValue({ + error: 'Host function failed', + } as unknown as SorobanRpc.Api.SimulateTransactionResponse); + + await expect(builder.simulate(tx)).rejects.toThrow('Simulation failed: Host function failed'); + }); + }); + + describe('sign & fee-bump', () => { + it('signs transaction directly with signer keypair', async () => { + const mockAccount = new Account(dummyKeypair.publicKey(), '100'); + mockRpcServer.getAccount.mockResolvedValue(mockAccount as unknown as Account); + + const tx = await builder.buildContractCall( + dummyContractId, + 'health', + [], + dummyKeypair.publicKey() + ); + + const signedTx = builder.sign(tx, dummyKeypair); + expect(signedTx.signatures.length).toBe(1); + }); + + it('creates fee-bump transaction when sponsor keypair is provided', async () => { + const mockAccount = new Account(dummyKeypair.publicKey(), '100'); + mockRpcServer.getAccount.mockResolvedValue(mockAccount as unknown as Account); + + const tx = await builder.buildContractCall( + dummyContractId, + 'health', + [], + dummyKeypair.publicKey() + ); + + const feeBumpTx = builder.sign(tx, dummyKeypair, sponsorKeypair); + expect(feeBumpTx).toBeInstanceOf(FeeBumpTransaction); + }); + }); + + describe('submit & retries', () => { + it('retries on TRY_AGAIN_LATER status and eventually succeeds', async () => { + const mockAccount = new Account(dummyKeypair.publicKey(), '100'); + mockRpcServer.getAccount.mockResolvedValue(mockAccount as unknown as Account); + + const tx = await builder.buildContractCall( + dummyContractId, + 'health', + [], + dummyKeypair.publicKey() + ); + + mockRpcServer.sendTransaction + .mockResolvedValueOnce({ status: 'TRY_AGAIN_LATER' } as unknown as SorobanRpc.Api.SendTransactionResponse) + .mockResolvedValueOnce({ status: 'TRY_AGAIN_LATER' } as unknown as SorobanRpc.Api.SendTransactionResponse) + .mockResolvedValueOnce({ status: 'PENDING', hash: 'txhash123' } as unknown as SorobanRpc.Api.SendTransactionResponse); + + mockRpcServer.getTransaction.mockResolvedValue({ + status: SorobanRpc.Api.GetTransactionStatus.SUCCESS, + returnValue: nativeToScVal('ok'), + } as unknown as SorobanRpc.Api.GetTransactionResponse); + + const result = await builder.submit(tx, { tryAgainDelayMs: 10, pollIntervalMs: 10 }); + expect(mockRpcServer.sendTransaction).toHaveBeenCalledTimes(3); + expect(result.status).toBe(SorobanRpc.Api.GetTransactionStatus.SUCCESS); + }); + + it('handles sequence number conflicts by fetching fresh account and retrying', async () => { + const mockAccount1 = new Account(dummyKeypair.publicKey(), '100'); + const mockAccount2 = new Account(dummyKeypair.publicKey(), '105'); + + mockRpcServer.getAccount + .mockResolvedValueOnce(mockAccount1 as unknown as Account) + .mockResolvedValueOnce(mockAccount2 as unknown as Account); + + const sorobanData = new xdr.SorobanTransactionData({ + resources: new xdr.SorobanResources({ + footprint: new xdr.LedgerFootprint({ readOnly: [], readWrite: [] }), + instructions: 100, + readBytes: 100, + writeBytes: 100, + }), + resourceFee: new xdr.Int64(1000), + ext: xdr.ExtensionPoint.fromXDR(Buffer.from([0, 0, 0, 0])), + }); + + const mockSimResult = { + minResourceFee: '1000', + results: [{ auth: [] }], + transactionData: sorobanData.toXDR('base64'), + }; + mockRpcServer.simulateTransaction.mockResolvedValue(mockSimResult as unknown as SorobanRpc.Api.SimulateTransactionResponse); + + const tx = await builder.buildContractCall( + dummyContractId, + 'health', + [], + dummyKeypair.publicKey() + ); + + mockRpcServer.sendTransaction + .mockRejectedValueOnce({ response: { status: 400 }, message: 'Bad sequence 400' }) + .mockResolvedValueOnce({ status: 'PENDING', hash: 'txhash456' } as unknown as SorobanRpc.Api.SendTransactionResponse); + + mockRpcServer.getTransaction.mockResolvedValue({ + status: SorobanRpc.Api.GetTransactionStatus.SUCCESS, + } as unknown as SorobanRpc.Api.GetTransactionResponse); + + const result = await builder.submit( + tx, + { tryAgainDelayMs: 10, pollIntervalMs: 10, autoRefreshSequenceOnConflict: true }, + { + contractId: dummyContractId, + functionName: 'health', + args: [], + sourcePublicKey: dummyKeypair.publicKey(), + signerKeypair: dummyKeypair, + } + ); + + expect(mockRpcServer.getAccount).toHaveBeenCalledTimes(2); + expect(result.status).toBe(SorobanRpc.Api.GetTransactionStatus.SUCCESS); + }); + }); + + describe('executeContractCall end-to-end', () => { + it('executes end-to-end chain build -> simulate -> sign -> submit', async () => { + const mockAccount = new Account(dummyKeypair.publicKey(), '100'); + mockRpcServer.getAccount.mockResolvedValue(mockAccount as unknown as Account); + + const sorobanData = new xdr.SorobanTransactionData({ + resources: new xdr.SorobanResources({ + footprint: new xdr.LedgerFootprint({ readOnly: [], readWrite: [] }), + instructions: 100, + readBytes: 100, + writeBytes: 100, + }), + resourceFee: new xdr.Int64(1000), + ext: xdr.ExtensionPoint.fromXDR(Buffer.from([0, 0, 0, 0])), + }); + + const mockSimResult = { + minResourceFee: '1000', + results: [{ auth: [] }], + transactionData: sorobanData.toXDR('base64'), + }; + mockRpcServer.simulateTransaction.mockResolvedValue(mockSimResult as unknown as SorobanRpc.Api.SimulateTransactionResponse); + + mockRpcServer.sendTransaction.mockResolvedValue({ + status: 'PENDING', + hash: 'e2ehash789', + } as unknown as SorobanRpc.Api.SendTransactionResponse); + + mockRpcServer.getTransaction.mockResolvedValue({ + status: SorobanRpc.Api.GetTransactionStatus.SUCCESS, + returnValue: nativeToScVal('success_val'), + } as unknown as SorobanRpc.Api.GetTransactionResponse); + + const res = await builder.executeContractCall( + dummyContractId, + 'register_player', + [strVal('player1')], + dummyKeypair, + { submitOptions: { pollIntervalMs: 10 } } + ); + + expect(res).toBe('success_val'); + }); + }); +}); diff --git a/tests/utils/positionAliases.test.ts b/tests/utils/positionAliases.test.ts index b19d7792..239f1d58 100644 --- a/tests/utils/positionAliases.test.ts +++ b/tests/utils/positionAliases.test.ts @@ -1,4 +1,8 @@ -import { normalizePosition, normalizePositionOrFallback, defaultPositionAliases } from '../../src/utils/positionAliases'; +import { + normalizePosition, + normalizePositionOrFallback, + defaultPositionAliases, +} from '../../src/utils/positionAliases'; describe('positionAliases', () => { test('normalizes common synonyms (fw -> forward)', () => { diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json new file mode 100644 index 00000000..b9ec3685 --- /dev/null +++ b/tsconfig.eslint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src", "tests"] +}