diff --git a/.env.example b/.env.example index b21f2fe..9a0ffa4 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,28 @@ -CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001 +# Server Configuration +# PORT is optional; defaults to the application default when omitted. +PORT=3001 +NODE_ENV=development + +# CORS Configuration +# FRONTEND_URL is mandatory at startup. FRONTEND_URL=http://localhost:3000 +# Optional backward-compatible fallback for multiple allowed origins during migration. +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001 +# Database and Queue Infrastructure +# DATABASE_URL and REDIS_URL are mandatory at startup. DATABASE_URL=postgres://postgres:postgres@db:5432/remitlend REDIS_URL=redis://redis:6379 # Stellar Configuration -# Select network defaults ("testnet" or "mainnet") +# STELLAR_RPC_URL, STELLAR_NETWORK_PASSPHRASE, LOAN_MANAGER_CONTRACT_ID, +# LENDING_POOL_CONTRACT_ID, POOL_TOKEN_ADDRESS, and LOAN_MANAGER_ADMIN_SECRET +# are mandatory at startup. +# Select network defaults ("testnet" or "mainnet"). STELLAR_NETWORK=testnet -# Optional override (defaults from STELLAR_NETWORK; must match selected network) +# Optional override (defaults from STELLAR_NETWORK; must match selected network). STELLAR_RPC_URL=https://soroban-testnet.stellar.org -# Optional override (must match selected network exactly) +# Optional override (must match selected network exactly). STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 LOAN_MANAGER_CONTRACT_ID= REMITTANCE_NFT_CONTRACT_ID= @@ -19,18 +32,22 @@ POOL_TOKEN_ADDRESS= STELLAR_USDC_ISSUER= STELLAR_EURC_ISSUER= STELLAR_PHP_ISSUER= -# Secret key for the on-chain LoanManager admin account (G... / S...) +# Secret key for the on-chain LoanManager admin account (G... / S...). LOAN_MANAGER_ADMIN_SECRET= -# Optional override for score reconciliation read calls +# Optional override for score reconciliation read calls. SCORE_RECONCILIATION_SOURCE_SECRET= -# Loan configuration (required) +# Loan Configuration LOAN_MIN_SCORE=500 LOAN_MAX_AMOUNT=50000 LOAN_INTEREST_RATE_PERCENT=12 CREDIT_SCORE_THRESHOLD=600 +# Must match the deployed contract if it ever changes from the repo default. +LOAN_TERM_LEDGERS=17280 -# Score Deltas (used by indexer to update user scores) +# Score Deltas +# SCORE_DELTA_REPAY, SCORE_DELTA_DEFAULT, and SCORE_DELTA_LATE are mandatory +# at startup and are used by the indexer to update user scores. SCORE_DELTA_REPAY=15 SCORE_DELTA_DEFAULT=50 SCORE_DELTA_LATE=5 @@ -40,23 +57,21 @@ INDEXER_POLL_INTERVAL_MS=30000 INDEXER_BATCH_SIZE=100 # Default checker (on-chain `check_defaults`) -# How often to scan + submit default checks while the API process is running +# How often to scan + submit default checks while the API process is running. DEFAULT_CHECK_INTERVAL_MS=1800000 -# Max loans to include per scheduled run (safety valve) +# Max loans to include per scheduled run (safety valve). DEFAULT_CHECK_MAX_LOANS_PER_RUN=500 -# Number of loan IDs per Soroban transaction +# Number of loan IDs per Soroban transaction. DEFAULT_CHECK_BATCH_SIZE=25 -# Max time to wait for a single batch submission before moving on +# Max time to wait for a single batch submission before moving on. DEFAULT_CHECK_BATCH_TIMEOUT_MS=300000 -# Number of concurrent batches to submit +# Number of concurrent batches to submit. DEFAULT_CHECK_CONCURRENCY=3 -# Polling configuration after submission +# Polling configuration after submission. DEFAULT_CHECK_POLL_ATTEMPTS=30 DEFAULT_CHECK_POLL_SLEEP_MS=1000 -# Must match the deployed contract if it ever changes from the repo default -LOAN_TERM_LEDGERS=17280 -# Score reconciliation +# Score Reconciliation SCORE_RECONCILIATION_INTERVAL_MS=3600000 SCORE_RECONCILIATION_MAX_BORROWERS_PER_RUN=500 SCORE_RECONCILIATION_BATCH_SIZE=25 @@ -64,16 +79,20 @@ SCORE_RECONCILIATION_AUTOCORRECT_ENABLED=false SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD=50 # Authentication +# JWT_SECRET and INTERNAL_API_KEY are mandatory at startup. JWT_SECRET=your-super-secret-jwt-key-change-in-production INTERNAL_API_KEY=change-me # Webhooks +# Per-subscription webhook signing secrets are stored when a webhook subscription +# is registered. WEBHOOK_REQUEST_TIMEOUT_MS and WEBHOOK_MAX_PAYLOAD_BYTES are optional. WEBHOOK_REQUEST_TIMEOUT_MS=30000 +WEBHOOK_MAX_PAYLOAD_BYTES=65536 # Sentry (leave blank to disable; set SENTRY_DSN in staging/production) SENTRY_DSN= -# Notifications +# Notifications and Cleanup Jobs NOTIFICATION_RETENTION_DAYS=90 READ_NOTIFICATION_RETENTION_DAYS=30 @@ -89,6 +108,3 @@ ADMIN_WEBHOOK_URL= TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= TWILIO_PHONE_NUMBER= - -# Redis Configuration (Local) -REDIS_URL=redis://localhost:6379 diff --git a/README.md b/README.md index 56ee6f7..550d007 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,22 @@ # RemitLend Backend API -Express.js backend service for the RemitLend platform, providing API endpoints for credit scoring, remittance simulation, and NFT metadata management. +Express.js backend service for the RemitLend platform, providing API endpoints for credit scoring, remittance simulation, loan indexing, webhook delivery, and NFT metadata management. ## Overview -The backend serves as a bridge between the frontend application and the Stellar blockchain, handling: +The backend serves as a bridge between the frontend application, PostgreSQL, Redis, and Stellar/Soroban contracts. It handles: -- **Loan Event Indexing**: A robust polling service that watches Soroban RPC for `LoanRequested`, `LoanApproved`, and `LoanRepaid` events. -- **Credit Scoring**: Generation and verification of borrower scores based on indexed history. -- **Remittance Simulation**: API support for generating mocked remittance data for testing. -- **NFT Metadata**: Serving metadata for the Remittance NFT collection. -- **Security**: Request validation, rate limiting, and centralized error handling. +- **Loan Event Indexing**: Polls Soroban RPC for loan, pool, NFT, score, and governance events. +- **Credit Scoring**: Updates borrower scores from indexed repayment, default, and late-payment history. +- **Remittance Simulation**: Provides mocked remittance data for local testing. +- **Webhook Delivery**: Dispatches signed event payloads to subscribed callback URLs and retries failures. +- **Background Maintenance**: Runs reconciliation, cleanup, default-checking, and loan-due jobs while the API process is alive. +- **Security**: Validates requests, rate limits traffic, validates startup configuration, and signs webhook payloads with HMAC-SHA256. ## Tech Stack -- **Runtime**: Node.js 18+ +- **Runtime**: Node.js 22.x +- **Container runtime**: `node:22-alpine` in the Dockerfile - **Framework**: Express.js 5 - **Language**: TypeScript - **Validation**: Zod @@ -22,12 +24,17 @@ The backend serves as a bridge between the frontend application and the Stellar - **Documentation**: Swagger/OpenAPI - **Code Quality**: ESLint + Prettier +> Keep local Node, Docker, and any `package.json` `engines.node` setting aligned on Node 22.x. The Dockerfile is the deployment source of truth and currently pins `node:22-alpine`. + ## Getting Started ### Prerequisites -- Node.js 18 or higher -- npm or yarn +- Node.js 22.x +- npm 10+ +- PostgreSQL +- Redis +- Stellar/Soroban contract IDs and admin credentials for contract-backed flows ### Installation @@ -38,7 +45,7 @@ npm install # Copy environment variables cp .env.example .env -# Apply database migrations (requires PostgreSQL and DATABASE_URL in .env) +# Fill the mandatory values in .env, then apply migrations npm run migrate:up # Start development server @@ -47,23 +54,23 @@ npm run dev ### Database and migrations -The API expects a PostgreSQL database. Set `DATABASE_URL` in `.env` (see `.env.example`). +The API expects a PostgreSQL database. Set `DATABASE_URL` in `.env` using `.env.example` as the template. -Apply schema migrations from the `backend` directory: +Apply schema migrations from the backend directory: ```bash npm run migrate:up ``` -Rollback last batch (when needed): +Rollback last batch when needed: ```bash npm run migrate:down ``` -Scripts use `migrate:up` and `migrate:down` (colon-separated names), which work reliably across shells and CI. +Scripts use `migrate:up` and `migrate:down` with colon-separated names, which work reliably across shells and CI. -Core tables are created by these migrations (run in filename order): +Core tables are created by these migrations in filename order: | Migration | Tables | | -------------------------------------------- | -------------------------------------------------- | @@ -73,102 +80,134 @@ Core tables are created by these migrations (run in filename order): | `1773000000001_user-profiles.js` | `user_profiles` | | `1773000000002_loan-history.js` | `loan_history` | | `1773000000003_indexed-events.js` | `indexed_events` | -| `1774000000004_scores-add-created-at.js` | adds `created_at` to `scores` (idempotent) | -| `1777000000008_unique-loan-status-events.js` | dedupes and enforces unique status events per loan | - -### Migration naming convention - -Each migration file follows the pattern `_.js`. The timestamp prefix is a 13-digit Unix millisecond value (generated by `Date.now().toString()`) that determines apply order — migrations run in strictly ascending timestamp order. - -To avoid collisions: - -- **Always** use `npm run migrate:create ` to generate new migrations — it calls `Date.now()` automatically and guarantees uniqueness. -- If you must create a migration file manually, first check the largest existing timestamp in `migrations/` and ensure your new prefix is strictly larger. -- Never submit a PR where two migration files share the same numeric prefix — the apply order becomes non-deterministic. - -### Renaming an already-applied migration (existing databases) - -If you rename a migration file that has already been applied to a database, `node-pg-migrate` will reject the run with: - -``` -Error: Not run migration is preceding already run migration +| `1774000000004_scores-add-created-at.js` | adds `created_at` to `scores` | +| `1777000000007_unique-loan-status-events.js` | dedupes and enforces unique status events per loan | + +With Docker Compose from the repo root, the backend service runs `migrate:up` before `npm run dev` so the schema is applied automatically when the database is healthy. + +## Environment Variables + +Copy `.env.example` to `.env` and keep the two files in sync when new configuration is introduced. Startup validation is implemented in `src/config/env.ts`; any variable in the mandatory table below must be present and non-empty or the process exits immediately. + +### Mandatory at startup + +| Variable | Purpose | +| --- | --- | +| `DATABASE_URL` | PostgreSQL connection string used by API queries, migrations, schedulers, and webhook persistence. | +| `REDIS_URL` | Redis connection string used by queue/cache-backed flows. | +| `JWT_SECRET` | Secret used for JWT signing/verification. Use a strong production value. | +| `STELLAR_RPC_URL` | Soroban RPC endpoint used by indexers and on-chain service calls. | +| `STELLAR_NETWORK_PASSPHRASE` | Stellar network passphrase; must match the selected network and deployed contracts. | +| `LOAN_MANAGER_CONTRACT_ID` | Soroban LoanManager contract ID used for loan lifecycle operations. | +| `LENDING_POOL_CONTRACT_ID` | Soroban LendingPool contract ID used for pool-related operations/events. | +| `POOL_TOKEN_ADDRESS` | Pool token/asset address used by pool and accounting flows. | +| `LOAN_MANAGER_ADMIN_SECRET` | Secret key for the admin account authorized to submit privileged LoanManager transactions. | +| `INTERNAL_API_KEY` | Shared secret for internal-only administrative endpoints/jobs. | +| `FRONTEND_URL` | Canonical frontend origin used by CORS and links. | +| `SCORE_DELTA_REPAY` | Score increment applied by indexer/reconciliation after successful repayment. | +| `SCORE_DELTA_DEFAULT` | Score decrement applied after loan default. | +| `SCORE_DELTA_LATE` | Score decrement applied after late payment/late-fee events. | + +### Other configuration in `.env.example` + +| Variable(s) | Required? | Purpose | +| --- | --- | --- | +| `PORT`, `NODE_ENV` | Optional | Server port and runtime environment. | +| `CORS_ALLOWED_ORIGINS` | Optional | Comma-separated additional CORS origins. `FRONTEND_URL` remains mandatory. | +| `STELLAR_NETWORK` | Optional | Selects network defaults such as `testnet` or `mainnet`. | +| `REMITTANCE_NFT_CONTRACT_ID`, `MULTISIG_GOVERNANCE_CONTRACT_ID` | Optional, feature-dependent | Contract IDs for NFT/governance flows when enabled. | +| `STELLAR_USDC_ISSUER`, `STELLAR_EURC_ISSUER`, `STELLAR_PHP_ISSUER` | Optional, feature-dependent | Stellar asset issuers for supported currencies. | +| `SCORE_RECONCILIATION_SOURCE_SECRET` | Optional | Override signing/source account for score reconciliation read calls. | +| `LOAN_MIN_SCORE`, `LOAN_MAX_AMOUNT`, `LOAN_INTEREST_RATE_PERCENT`, `CREDIT_SCORE_THRESHOLD`, `LOAN_TERM_LEDGERS` | Optional/defaulted by config | Loan policy values and contract term assumptions. Keep `LOAN_TERM_LEDGERS` aligned with deployed contracts. | +| `INDEXER_POLL_INTERVAL_MS`, `INDEXER_BATCH_SIZE` | Optional | Controls indexer polling cadence and ledger/event batch size. | +| `DEFAULT_CHECK_INTERVAL_MS`, `DEFAULT_CHECK_MAX_LOANS_PER_RUN`, `DEFAULT_CHECK_BATCH_SIZE`, `DEFAULT_CHECK_BATCH_TIMEOUT_MS`, `DEFAULT_CHECK_CONCURRENCY`, `DEFAULT_CHECK_POLL_ATTEMPTS`, `DEFAULT_CHECK_POLL_SLEEP_MS` | Optional | Controls the scheduled on-chain default checker. | +| `SCORE_RECONCILIATION_INTERVAL_MS`, `SCORE_RECONCILIATION_MAX_BORROWERS_PER_RUN`, `SCORE_RECONCILIATION_BATCH_SIZE`, `SCORE_RECONCILIATION_AUTOCORRECT_ENABLED`, `SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD` | Optional | Controls score reconciliation cadence, batching, and optional autocorrection. | +| `WEBHOOK_REQUEST_TIMEOUT_MS`, `WEBHOOK_MAX_PAYLOAD_BYTES` | Optional | Controls outbound webhook timeout and payload-size summary behavior. | +| `SENTRY_DSN` | Optional | Enables Sentry error reporting when provided. | +| `NOTIFICATION_RETENTION_DAYS`, `READ_NOTIFICATION_RETENTION_DAYS` | Optional | Controls notification cleanup retention windows. | +| `SENDGRID_API_KEY`, `FROM_EMAIL` | Optional, feature-dependent | Email notification configuration. | +| `ADMIN_EMAIL`, `ADMIN_WEBHOOK_URL` | Optional | Admin alert destinations. | +| `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_PHONE_NUMBER` | Optional, feature-dependent | SMS notification configuration. | + +## Background Jobs and Schedulers + +These jobs run inside the API process. In production, run only one scheduler-active instance unless the job implementation is explicitly made distributed-lock safe. + +| Job | Purpose | Main configuration | +| --- | --- | --- | +| **Indexer** | Polls Soroban RPC for contract events, persists normalized events, updates score/loan state, and dispatches webhooks. | `INDEXER_POLL_INTERVAL_MS`, `INDEXER_BATCH_SIZE`, contract IDs, Stellar RPC/network values, score deltas. | +| **Default checker** | Periodically scans eligible loans and submits on-chain `check_defaults` transactions in controlled batches. | `DEFAULT_CHECK_INTERVAL_MS`, `DEFAULT_CHECK_MAX_LOANS_PER_RUN`, `DEFAULT_CHECK_BATCH_SIZE`, `DEFAULT_CHECK_BATCH_TIMEOUT_MS`, `DEFAULT_CHECK_CONCURRENCY`, `DEFAULT_CHECK_POLL_ATTEMPTS`, `DEFAULT_CHECK_POLL_SLEEP_MS`, `LOAN_MANAGER_ADMIN_SECRET`. | +| **Webhook retry** | Finds failed webhook deliveries whose `next_retry_at` is due and retries them. Current retry schedule is roughly 5 minutes, 15 minutes, then 45 minutes after failure. | `WEBHOOK_REQUEST_TIMEOUT_MS`, `WEBHOOK_MAX_PAYLOAD_BYTES`, per-subscription webhook secret. | +| **Score reconciliation** | Compares stored scores with on-chain/derived state and optionally autocorrects drift beyond a configured threshold. | `SCORE_RECONCILIATION_INTERVAL_MS`, `SCORE_RECONCILIATION_MAX_BORROWERS_PER_RUN`, `SCORE_RECONCILIATION_BATCH_SIZE`, `SCORE_RECONCILIATION_AUTOCORRECT_ENABLED`, `SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD`, `SCORE_RECONCILIATION_SOURCE_SECRET`. | +| **Cleanup** | Removes expired/read notifications and other old operational records according to retention settings. | `NOTIFICATION_RETENTION_DAYS`, `READ_NOTIFICATION_RETENTION_DAYS`. | +| **Loan-due cron** | Periodically checks loans approaching or passing due status so the system can surface due/default-related state and notifications. | Loan tables, `LOAN_TERM_LEDGERS`, default-checker settings, notification settings. | + +## Webhook HMAC Signatures + +When a webhook subscription has a `secret`, every outbound webhook request includes: + +```http +x-remitlend-signature: +content-type: application/json ``` -This happens because `pgmigrations` still references the old filename while the filesystem has the new one. Fix it by syncing the tracking table before re-running `npm run migrate:up`: - -```sql -UPDATE pgmigrations -SET name = '1777000000008_unique-loan-status-events' -WHERE name = '1777000000007_unique-loan-status-events'; - -UPDATE pgmigrations -SET name = '1778000000009_transaction-submissions' -WHERE name = '1778000000008_transaction-submissions'; +The signature is produced from the exact raw JSON request body sent to the callback URL: -UPDATE pgmigrations -SET name = '1786000000017_webhook-max-attempts' -WHERE name = '1786000000016_webhook-max-attempts'; - -UPDATE pgmigrations -SET name = '1788000000019_unified-contract-events' -WHERE name = '1788000000018_unified-contract-events'; +```ts +signature = HMAC_SHA256_HEX(secret, rawBody); ``` -Afterwards, `npm run migrate:up` will run normally. - -With Docker Compose from the repo root, the `backend` service runs `migrate:up` before `npm run dev` so the schema is applied automatically when the database is healthy. +In code, `src/services/webhookService.ts` centralizes this in `createWebhookSignature(body, secret)`. The same file also exports `verifyWebhookSignature(body, signature, secret)` so tests or consumers can use the same timing-safe verification logic. -### Environment Variables +A webhook receiver should verify before parsing or mutating the payload: -Create a `.env` file in the backend directory: +```ts +import crypto from "node:crypto"; -```env -# Server Configuration -PORT=3001 +function verifyRemitLendWebhook(rawBody: string, signature: string, secret: string) { + const expected = Buffer.from( + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"), + "hex", + ); + const received = Buffer.from(signature, "hex"); -# CORS Configuration -FRONTEND_URL=http://localhost:3000 -# Optional backward-compatible fallback for multiple allowed origins during migration -# CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001 + return expected.length === received.length && crypto.timingSafeEqual(expected, received); +} +``` -# Stellar Configuration -STELLAR_NETWORK=testnet -STELLAR_RPC_URL=https://soroban-testnet.stellar.org -STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 -LOAN_MANAGER_CONTRACT_ID= -LOAN_MANAGER_ADMIN_SECRET= +Important verification rules: -# Future: Add API keys for remittance services -# WISE_API_KEY=your_key_here -# WESTERN_UNION_API_KEY=your_key_here -``` +- Use the raw request body bytes/string exactly as received, not a re-serialized JSON object. +- Treat a missing `x-remitlend-signature` as invalid when the subscription has a secret. +- Compare digests with a timing-safe comparison. +- Rotate subscription secrets by registering/updating the webhook secret and coordinating the receiver deployment. ## Available Scripts ```bash # Development -npm run dev # Start dev server with hot reload +npm run dev # Start dev server with hot reload # Database -npm run migrate:up # Apply migrations (requires DATABASE_URL) -npm run migrate:down # Roll back last migration batch -npm run seed # Seed realistic local development data -npm run seed:reset # Reset and reseed development data +npm run migrate:up # Apply migrations (requires DATABASE_URL) +npm run migrate:down # Roll back last migration batch +npm run seed # Seed realistic local development data +npm run seed:reset # Reset and reseed development data # Production -npm run build # Compile TypeScript to JavaScript -npm start # Run production build +npm run build # Compile TypeScript to JavaScript +npm start # Run production build # Testing -npm test # Run test suite -npm test -- --watch # Run tests in watch mode -npm test -- --coverage # Run tests with coverage +npm test # Run test suite +npm test -- --watch # Run tests in watch mode +npm test -- --coverage # Run tests with coverage # Code Quality -npm run lint # Check code quality -npm run lint:fix # Fix linting issues -npm run format # Format code with Prettier -npm run format:check # Check code formatting +npm run lint # Check code quality +npm run lint:fix # Fix linting issues +npm run format # Format code with Prettier +npm run format:check # Check code formatting ``` ### Development seed data @@ -179,15 +218,7 @@ New contributors can populate a realistic local dataset after running migrations npm run seed ``` -This seeds: - -- `user_profiles` with sample borrowers and a lender -- `scores` with varied borrower scores -- `remittance_history` with completed, late, missed, and pending records -- `loan_history` with pending, active, repaid, and defaulted loans -- `loan_events` so borrower dashboards, loan details, pool stats, and SSE endpoints have data -- `notifications` with both read and unread sample messages -- `indexer_state` so interest calculations have a seeded latest ledger +This seeds sample profiles, scores, remittance history, loan history, loan events, notifications, and indexer state so dashboards and event-driven flows have local data. To wipe those local development rows and recreate them from scratch: @@ -195,299 +226,12 @@ To wipe those local development rows and recreate them from scratch: npm run seed:reset ``` -## API Endpoints - -### Health Check - -**GET** `/api/health` - -Check if the API is running. - -**Response:** - -```json -{ - "status": "ok", - "timestamp": "2024-01-15T10:30:00.000Z" -} -``` - -### Credit Score - -**GET** `/api/score/:userId` - -Get the credit score for a specific user based on their remittance history. - -**Parameters:** - -- `userId` (string) - User identifier - -**Response:** - -```json -{ - "userId": "user123", - "score": 750, - "history": { - "totalTransactions": 24, - "averageAmount": 500, - "consistency": 0.95 - }, - "calculatedAt": "2024-01-15T10:30:00.000Z" -} -``` - -**Error Responses:** - -- `400` - Invalid user ID -- `404` - User not found -- `500` - Server error - -### Simulate Remittance - -**POST** `/api/score/simulate` - -Simulate remittance history for testing purposes. - -**Request Body:** - -```json -{ - "userId": "user123", - "transactions": [ - { - "amount": 500, - "date": "2024-01-01", - "recipient": "family_member" - } - ] -} -``` - -**Response:** - -```json -{ - "userId": "user123", - "score": 750, - "simulationId": "sim_abc123" -} -``` - ## API Documentation Interactive API documentation is available via Swagger UI when the server is running: **URL**: [http://localhost:3001/api-docs](http://localhost:3001/api-docs) -The Swagger documentation provides: - -- Complete endpoint specifications -- Request/response schemas -- Interactive API testing -- Authentication details (when implemented) - -## Project Structure - -``` -backend/ -├── src/ -│ ├── __tests__/ # Test files -│ │ ├── health.test.ts -│ │ ├── score.test.ts -│ │ ├── validation.test.ts -│ │ └── errorHandling.test.ts -│ ├── config/ # Configuration files -│ │ └── swagger.ts # Swagger/OpenAPI config -│ ├── controllers/ # Request handlers -│ │ ├── scoreController.ts -│ │ └── simulationController.ts -│ ├── middleware/ # Express middleware -│ │ ├── asyncHandler.ts # Async error wrapper -│ │ ├── auth.ts # Authentication (planned) -│ │ ├── errorHandler.ts # Error handling -│ │ ├── rateLimiter.ts # Rate limiting -│ │ └── validation.ts # Request validation -│ ├── routes/ # API routes -│ │ └── index.ts -│ ├── schemas/ # Zod validation schemas -│ │ ├── scoreSchemas.ts -│ │ └── simulationSchemas.ts -│ ├── errors/ # Custom error classes -│ │ └── AppError.ts -│ ├── app.ts # Express app setup -│ └── index.ts # Server entry point -├── .env.example # Environment template -├── .eslintrc.cjs # ESLint configuration -├── .prettierrc # Prettier configuration -├── jest.config.js # Jest configuration -├── tsconfig.json # TypeScript configuration -├── package.json -└── README.md -``` - -## Middleware - -### Error Handler - -Centralized error handling middleware that catches and formats errors. - -```typescript -import { errorHandler } from "./middleware/errorHandler"; -app.use(errorHandler); -``` - -### Validation - -Request validation using Zod schemas. - -```typescript -import { validate } from "./middleware/validation"; -import { mySchema } from "./schemas/mySchemas"; - -router.post("/endpoint", validate(mySchema), controller); -``` - -### Rate Limiter - -Protects endpoints from abuse with configurable rate limits. - -```typescript -import { rateLimiter } from "./middleware/rateLimiter"; -app.use("/api/", rateLimiter); -``` - -### Async Handler - -Wraps async route handlers to catch errors automatically. - -```typescript -import { asyncHandler } from "./middleware/asyncHandler"; - -router.get( - "/endpoint", - asyncHandler(async (req, res) => { - // Async code here - }), -); -``` - -## Testing - -### Running Tests - -```bash -# Run all tests -npm test - -# Run specific test file -npm test -- health.test.ts - -# Run with coverage -npm test -- --coverage - -# Watch mode -npm test -- --watch -``` - -### Test Structure - -```typescript -import request from "supertest"; -import app from "../app"; - -describe("GET /api/health", () => { - it("should return 200 OK", async () => { - const response = await request(app).get("/api/health").expect(200); - - expect(response.body).toHaveProperty("status", "ok"); - }); -}); -``` - -### Test Coverage - -Aim for >80% code coverage on new code. Current coverage: - -- Statements: Check with `npm test -- --coverage` -- Branches: Check with `npm test -- --coverage` -- Functions: Check with `npm test -- --coverage` -- Lines: Check with `npm test -- --coverage` - -## Error Handling - -### Custom Error Class - -```typescript -import { AppError } from "./errors/AppError"; - -throw new AppError("User not found", 404); -``` - -### Error Response Format - -```json -{ - "success": false, - "message": "Error message", - "statusCode": 400, - "errors": [] -} -``` - -## Validation Schemas - -Validation schemas are defined using Zod in the `schemas/` directory. - -### Example Schema - -```typescript -import { z } from "zod"; - -export const getUserScoreSchema = z.object({ - params: z.object({ - userId: z.string().min(1, "User ID is required"), - }), -}); - -export const simulateRemittanceSchema = z.object({ - body: z.object({ - userId: z.string().min(1), - transactions: z.array( - z.object({ - amount: z.number().positive(), - date: z.string(), - recipient: z.string(), - }), - ), - }), -}); -``` - -## Future Enhancements - -### Phase 1: Real Remittance Integration - -- [ ] Wise API integration -- [ ] Western Union API integration -- [ ] Remittance data verification -- [ ] Historical data import - -### Phase 2: Enhanced Features - -- [ ] User authentication (JWT) -- [ ] Database integration (PostgreSQL) -- [ ] IPFS integration for NFT metadata -- [ ] Webhook support for blockchain events - -### Phase 3: Production Ready - -- [ ] Caching layer (Redis) -- [ ] Logging and monitoring -- [ ] CI/CD pipeline -- [ ] Load balancing support -- [ ] API versioning - ## Deployment ### Docker @@ -510,13 +254,13 @@ docker compose up backend ### Production Considerations -- Set `NODE_ENV=production` -- Use process manager (PM2, systemd) -- Enable HTTPS -- Configure proper CORS origins -- Set up monitoring and logging -- Implement health checks -- Use environment-specific configs +- Set `NODE_ENV=production`. +- Use Node 22.x, matching `node:22-alpine` in the Dockerfile. +- Use strong production values for `JWT_SECRET`, `INTERNAL_API_KEY`, webhook subscription secrets, and Stellar admin secrets. +- Configure exact CORS origins. +- Run migrations before serving traffic. +- Ensure scheduler jobs are not duplicated across multiple replicas unless protected by a lock. +- Enable HTTPS, monitoring, logging, and health checks. ## Contributing @@ -524,17 +268,16 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md) for guidelines on contributing to the ### Code Style -- Follow TypeScript best practices -- Use async/await over callbacks -- Maintain strict typing -- Write descriptive variable names -- Add JSDoc comments for public functions -- Keep functions small and focused +- Follow TypeScript best practices. +- Use async/await over callbacks. +- Maintain strict typing. +- Write descriptive variable names. +- Add JSDoc comments for public functions. +- Keep functions small and focused. ### Before Submitting PR ```bash -# Run all checks npm run lint npm run format:check npm test @@ -546,14 +289,12 @@ npm run build ### Port Already in Use ```bash -# Find and kill process on port 3001 lsof -ti:3001 | xargs kill -9 ``` ### TypeScript Errors ```bash -# Clean build rm -rf dist/ npm run build ``` @@ -561,7 +302,6 @@ npm run build ### Module Not Found ```bash -# Reinstall dependencies rm -rf node_modules package-lock.json npm install ``` @@ -572,7 +312,7 @@ ISC License - See LICENSE file for details. ## Support -- Open an issue for bug reports -- Check existing issues before creating new ones -- Provide detailed reproduction steps -- Include error messages and logs +- Open an issue for bug reports. +- Check existing issues before creating new ones. +- Provide detailed reproduction steps. +- Include error messages and logs. diff --git a/src/config/env.ts b/src/config/env.ts index 5abcca3..f93f89c 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,11 +1,11 @@ import logger from "../utils/logger.js"; /** - * List of environment variables required for the application to function. - * If any of these are missing or empty on startup, the server will exit immediately - * with a clear error message. + * Environment variables that must be present and non-empty before the API starts. + * Keep this list in sync with the mandatory-startup table in README.md and the + * required entries in .env.example. */ -const REQUIRED_ENV_VARS = [ +export const REQUIRED_ENV_VARS = [ "DATABASE_URL", "REDIS_URL", "JWT_SECRET", @@ -20,7 +20,7 @@ const REQUIRED_ENV_VARS = [ "SCORE_DELTA_REPAY", "SCORE_DELTA_DEFAULT", "SCORE_DELTA_LATE", -]; +] as const; /** * Validates that all critical environment variables are set and non-empty. diff --git a/src/services/webhookService.ts b/src/services/webhookService.ts index 964c14c..d4f67da 100644 --- a/src/services/webhookService.ts +++ b/src/services/webhookService.ts @@ -109,6 +109,42 @@ interface PreparedWebhookPayload { payload: Record; } +/** + * Creates the exact value sent in the x-remitlend-signature header. + * The signature is an HMAC-SHA256 hex digest over the raw JSON request body + * using the webhook subscription secret as the key. + */ +export function createWebhookSignature(body: string, secret: string): string { + return crypto.createHmac("sha256", secret).update(body).digest("hex"); +} + +/** + * Verifies a received x-remitlend-signature header against the raw request body. + * Consumers should run this before parsing or mutating the JSON payload. + */ +export function verifyWebhookSignature( + body: string, + signature: string | undefined, + secret: string, +): boolean { + if (!signature) { + return false; + } + + try { + const expected = Buffer.from(createWebhookSignature(body, secret), "hex"); + const received = Buffer.from(signature, "hex"); + + if (expected.length !== received.length) { + return false; + } + + return crypto.timingSafeEqual(expected, received); + } catch { + return false; + } +} + function parsePositiveInt(value: string | undefined, fallback: number): number { const parsed = Number.parseInt(value ?? "", 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; @@ -356,9 +392,7 @@ export class WebhookService { const preparedPayload = prepareWebhookPayload(payload); const body = preparedPayload.body; - const signature = secret - ? crypto.createHmac("sha256", secret).update(body).digest("hex") - : undefined; + const signature = secret ? createWebhookSignature(body, secret) : undefined; let response: Response | null = null; @@ -594,9 +628,7 @@ export class WebhookService { ): Promise { const body = payload.body; - const signature = secret - ? crypto.createHmac("sha256", secret).update(body).digest("hex") - : undefined; + const signature = secret ? createWebhookSignature(body, secret) : undefined; try { const response = await postWebhook(callbackUrl, body, signature);