diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index d660c52a..d28ca25e 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -60,6 +60,8 @@ Always back up the database file before running migrations in production. 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) diff --git a/README.md b/README.md index 0a463a66..2a0e535a 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,7 @@ 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 | |--------|------|------|-------------| diff --git a/__mocks__/better-sqlite3.js b/__mocks__/better-sqlite3.js index b9153cca..66d280f1 100644 --- a/__mocks__/better-sqlite3.js +++ b/__mocks__/better-sqlite3.js @@ -17,10 +17,42 @@ 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 (row.expires_at <= threshold) { + this._db._idempotencyRows.delete(key); + deleted += 1; + } + } + return { changes: deleted, lastInsertRowid: 0 }; + } + return { changes: 1, lastInsertRowid: 0 }; } @@ -31,6 +63,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 +93,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 +108,7 @@ class Database { constructor(_path) { this._events = []; this._state = new Map(); + this._idempotencyRows = new Map(); } exec(_sql) { 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/docs/runbooks/dependency-outages.md b/docs/runbooks/dependency-outages.md new file mode 100644 index 00000000..3957c398 --- /dev/null +++ b/docs/runbooks/dependency-outages.md @@ -0,0 +1,217 @@ +# 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.json b/package.json index a48f16ec..6c066986 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "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", + "purge:idempotency-keys": "npm run build && node dist/scripts/purgeIdempotencyKeys.js" }, "dependencies": { "@stellar/stellar-sdk": "12.1.0", diff --git a/src/config.ts b/src/config.ts index 734604b5..263cce19 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,6 +19,14 @@ 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; +} + const config = { port: parseInt(process.env.PORT ?? '4000', 10), network: (process.env.NETWORK ?? 'testnet') as 'testnet' | 'mainnet', @@ -39,6 +47,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,7 +55,6 @@ 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 ?? '' @@ -56,6 +64,10 @@ const config = { 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), + }, }; export default config; diff --git a/src/controllers/adminController.ts b/src/controllers/adminController.ts index 5d3650a2..eb6bfbc1 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}$/; diff --git a/src/controllers/playerController.ts b/src/controllers/playerController.ts index 0ec48dc9..8186b40e 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), @@ -92,6 +98,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; diff --git a/src/controllers/validatorController.ts b/src/controllers/validatorController.ts index 0553cbd2..e2f0a049 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'); } diff --git a/src/index.ts b/src/index.ts index db39efed..482c9a5e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,8 @@ 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 } from './middleware/idempotency'; import { indexEvents } from './services/indexer'; import { logger } from './utils/logger'; import { stellarHealth } from './services/stellar'; @@ -21,6 +23,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 +44,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 @@ -70,7 +73,10 @@ app.get('/health/readiness', async (_req, res) => { } else { res.status(503).json({ status: 'degraded', services }); } -}); +}; + +app.get('/health/readiness', readinessHandler); +app.get('/ready', readinessHandler); app.use('/auth', authRoutes); app.use('/api/players', playerRoutes); @@ -118,6 +124,7 @@ app.listen(config.port, () => { poll(); setInterval(poll, 5_000); + startIdempotencyPurgeJob(); }); export default app; diff --git a/src/middleware/idempotency.ts b/src/middleware/idempotency.ts new file mode 100644 index 00000000..9a4ef1a7 --- /dev/null +++ b/src/middleware/idempotency.ts @@ -0,0 +1,144 @@ +import Database from 'better-sqlite3'; +import fs from 'fs'; +import path from 'path'; +import type { NextFunction, Request, Response } from 'express'; +import config from '../config'; + +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); + `); +} + +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(); +} diff --git a/src/middleware/responseTime.ts b/src/middleware/responseTime.ts index 058734c1..fd48dcee 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..76d26058 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,5 +1,5 @@ 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/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/ipfs.ts b/src/services/ipfs.ts index 225877b1..4f89e542 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,6 +15,9 @@ 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; } @@ -52,6 +28,9 @@ export async function pinFile( 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 +54,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/stellar.ts b/src/services/stellar.ts index a3b6e3b8..1a0982a7 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'; @@ -105,15 +67,3 @@ export async function submitContactPayment( }; } -/** - * 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/webhooks.ts b/src/services/webhooks.ts index 8d7d10ac..902e108a 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; } diff --git a/tests/middleware/idempotency.test.ts b/tests/middleware/idempotency.test.ts new file mode 100644 index 00000000..2b11ed65 --- /dev/null +++ b/tests/middleware/idempotency.test.ts @@ -0,0 +1,39 @@ +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/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/services/webhooks.test.ts b/tests/services/webhooks.test.ts index 1d2a1493..5909d43b 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,30 @@ 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); + 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 }) ).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 }) ).rejects.toThrow('network down'); - expect(mockedFetch).toHaveBeenCalledTimes(2); + expect(mockedAxios.post).toHaveBeenCalledTimes(2); }); });