diff --git a/.env.example b/.env.example index ec0e38c..a17d8e1 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,30 @@ JWT_SECRET=change-me-in-production PORT=3001 DISCORD_WEBHOOK_URL= +# Runtime environment: development | production | test. Controls CORS wildcard +# rules, Sentry activation, migration-on-startup, and more. +NODE_ENV=development +# Base URL advertised as the "Development" server in the generated Swagger docs. +API_URL=http://localhost:3001 +# Comma-separated Stellar public keys (G...) allowed to hit /admin/* endpoints. +ADMIN_ADDRESSES= +# Sentry error reporting DSN. Empty -> Sentry is disabled (no-op). +SENTRY_DSN= + +# Abuse detection layered on top of the base rate limiter: an identity that +# trips the limit RATE_LIMIT_ABUSE_THRESHOLD times within +# RATE_LIMIT_ABUSE_WINDOW_SECONDS is locked out for RATE_LIMIT_LOCKOUT_SECONDS. +RATE_LIMIT_ABUSE_WINDOW_SECONDS=300 +RATE_LIMIT_ABUSE_THRESHOLD=5 +RATE_LIMIT_LOCKOUT_SECONDS=900 + +# TTL for stored Idempotency-Key records (see IdempotencyKeyService). Default 24h. +IDEMPOTENCY_KEY_TTL_SECONDS=86400 + +# Escrow reconciliation sweep — re-checks on-chain escrow state against the DB. +# Milliseconds; 0 or negative disables the background sweep. Default 10 minutes. +ESCROW_RECONCILIATION_SWEEP_INTERVAL_MS=600000 + # CORS configuration — required in production when running with credentials: true # Set to a comma-separated list of allowed origins, e.g., https://app.example.com,https://admin.example.com # Wildcard '*' is only allowed in development (NODE_ENV != production) diff --git a/backend/.husky/pre-commit b/backend/.husky/pre-commit new file mode 100755 index 0000000..c7d0529 --- /dev/null +++ b/backend/.husky/pre-commit @@ -0,0 +1 @@ +npx --no-install lint-staged --cwd backend diff --git a/backend/SETUP_INSTRUCTIONS.md b/backend/SETUP_INSTRUCTIONS.md index e1d73a8..e8d4701 100644 --- a/backend/SETUP_INSTRUCTIONS.md +++ b/backend/SETUP_INSTRUCTIONS.md @@ -92,6 +92,22 @@ npm run test:cov npm run test:ci ``` +### Pre-commit hook + +`npm install` (in `backend/`) wires up a Husky `pre-commit` hook via the +`prepare` script. It runs `lint-staged` over staged `.ts` files under +`backend/` — `eslint --fix` then `prettier --write` — so a lint or formatting +violation is fixed (or blocks the commit) locally, before it reaches the +`lint:check` / `format:check` gates in `.github/workflows/backend-ci.yml`. + +The hook script is `backend/.husky/pre-commit`; the file globs and commands +are the `lint-staged` block in `backend/package.json`. To bypass it for a +single commit (rarely needed): `git commit --no-verify`. + +Because the repository keeps the Node project in `backend/` while `.git` is at +the repo root, `prepare` runs Husky from the repo root and the hook invokes +`lint-staged --cwd backend`. + ### Code Quality ```bash diff --git a/backend/package.json b/backend/package.json index 8003192..2caea44 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,7 +15,14 @@ "lint": "eslint \"src/**/*.ts\" --fix", "lint:check": "eslint \"src/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\"", - "format:check": "prettier --check \"src/**/*.ts\"" + "format:check": "prettier --check \"src/**/*.ts\"", + "prepare": "cd .. && husky backend/.husky || true" + }, + "lint-staged": { + "*.ts": [ + "eslint --fix", + "prettier --write" + ] }, "dependencies": { "@nestjs/common": "^10.0.0", @@ -52,7 +59,9 @@ "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", + "husky": "^9.1.7", "jest": "^29.5.0", + "lint-staged": "^15.2.10", "prettier": "^3.0.0", "supertest": "^6.3.0", "ts-jest": "^29.1.0", diff --git a/backend/src/common/redis/redis.module.spec.ts b/backend/src/common/redis/redis.module.spec.ts new file mode 100644 index 0000000..650de23 --- /dev/null +++ b/backend/src/common/redis/redis.module.spec.ts @@ -0,0 +1,52 @@ +import { Logger } from '@nestjs/common'; +import type { Redis } from 'ioredis'; +import { createRedisClient } from './redis.module'; + +describe('createRedisClient (#220)', () => { + const originalUrl = process.env.REDIS_URL; + const clients: Redis[] = []; + + afterEach(() => { + for (const c of clients.splice(0)) c.disconnect(); + if (originalUrl === undefined) delete process.env.REDIS_URL; + else process.env.REDIS_URL = originalUrl; + jest.restoreAllMocks(); + }); + + it('returns null and warns when REDIS_URL is unset', () => { + delete process.env.REDIS_URL; + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + + expect(createRedisClient()).toBeNull(); + expect(warn).toHaveBeenCalled(); + }); + + it('always registers an error listener so a connection failure cannot go unhandled', () => { + process.env.REDIS_URL = 'redis://127.0.0.1:1'; // nothing listens here + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + + const client = createRedisClient(); + expect(client).not.toBeNull(); + clients.push(client!); + + expect(client!.listenerCount('error')).toBeGreaterThanOrEqual(1); + }); + + it('logs (does not throw) when the eager connection fails', async () => { + process.env.REDIS_URL = 'redis://127.0.0.1:1'; + const error = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + + const client = createRedisClient(); + clients.push(client!); + + // Give the eager connect().catch() a tick to run. + await new Promise(r => setTimeout(r, 50)); + expect(error).toHaveBeenCalledWith( + expect.stringContaining('Initial Redis connection failed'), + ); + }); +}); diff --git a/backend/src/common/redis/redis.module.ts b/backend/src/common/redis/redis.module.ts index 05bd113..f032c44 100644 --- a/backend/src/common/redis/redis.module.ts +++ b/backend/src/common/redis/redis.module.ts @@ -1,24 +1,60 @@ -import { Module, Global } from '@nestjs/common'; +import { Module, Global, Logger } from '@nestjs/common'; import { Redis } from 'ioredis'; import { DistributedLockService } from './distributed-lock.service'; export const REDIS_CLIENT = 'REDIS_CLIENT'; +/** + * Build the app's ioredis client from `REDIS_URL`, or `null` when it is unset. + * + * ioredis is a Node `EventEmitter`, and an unhandled `'error'` event can crash + * the process on some Node/ioredis versions; even where it doesn't, a + * connection failure otherwise only surfaces as a rejected promise on the next + * command. So an `'error'` listener is always attached and every failure is + * logged via the NestJS `Logger` (#220). + * + * The connection is also attempted eagerly rather than waiting for the first + * real command (`lazyConnect: true`), so a misconfigured `REDIS_URL` shows up + * at startup. This never fails startup: `connect()`'s rejection is caught and + * logged, ioredis keeps retrying per `retryStrategy`, and Redis-backed + * features degrade until it recovers. + */ +export function createRedisClient(logger: Logger = new Logger('RedisModule')): Redis | null { + const url = process.env.REDIS_URL; + if (!url) { + logger.warn( + 'REDIS_URL not set — Redis-backed features (rate limiting, outbox relay, caches) are disabled', + ); + return null; + } + + const client = new Redis(url, { + maxRetriesPerRequest: 3, + retryStrategy: times => Math.min(times * 100, 3000), + lazyConnect: true, + }); + + client.on('error', err => + logger.error(`Redis client error: ${err.message}`, err.stack), + ); + client.on('connect', () => logger.log('Redis connected')); + client.on('reconnecting', () => logger.warn('Redis reconnecting…')); + + client.connect().catch((err: Error) => + logger.error( + `Initial Redis connection failed (will keep retrying per retryStrategy): ${err.message}`, + ), + ); + + return client; +} + @Global() @Module({ providers: [ { provide: REDIS_CLIENT, - useFactory: () => { - const url = process.env.REDIS_URL; - if (!url) return null; - const client = new Redis(url, { - maxRetriesPerRequest: 3, - retryStrategy: times => Math.min(times * 100, 3000), - lazyConnect: true, - }); - return client; - }, + useFactory: (): Redis | null => createRedisClient(), }, DistributedLockService, ], diff --git a/backend/src/env-example-coverage.spec.ts b/backend/src/env-example-coverage.spec.ts new file mode 100644 index 0000000..e1ffc73 --- /dev/null +++ b/backend/src/env-example-coverage.spec.ts @@ -0,0 +1,74 @@ +import { readFileSync, readdirSync } from 'fs'; +import { join } from 'path'; + +/** + * #222 — every `process.env.X` referenced under `backend/src` must be + * documented in the repo-root `.env.example`, so a new contributor can + * discover every knob without grepping the source. + */ + +const REPO_ROOT = join(__dirname, '..', '..'); +const SRC_DIR = __dirname; +const ENV_EXAMPLE = join(REPO_ROOT, '.env.example'); + +/** Runtime facts, not configuration — set by the runner, not copied from `.env.example`. */ +const RUNTIME_ONLY = new Set(['NODE_ENV']); + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...walk(full)); + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')) out.push(full); + } + return out; +} + +function referencedEnvVars(): Set { + const vars = new Set(); + for (const file of walk(SRC_DIR)) { + const source = readFileSync(file, 'utf8'); + for (const match of source.matchAll(/process\.env\.([A-Z_][A-Z0-9_]*)/g)) { + vars.add(match[1]); + } + } + return vars; +} + +function documentedEnvVars(): Set { + const text = readFileSync(ENV_EXAMPLE, 'utf8'); + const vars = new Set(); + for (const line of text.split('\n')) { + const match = line.match(/^\s*#?\s*([A-Z_][A-Z0-9_]*)=/); + if (match) vars.add(match[1]); + } + return vars; +} + +describe('.env.example coverage (#222)', () => { + const referenced = referencedEnvVars(); + const documented = documentedEnvVars(); + + it('references at least the known set of env vars (sanity check that the grep works)', () => { + expect(referenced.has('REDIS_URL')).toBe(true); + expect(referenced.has('JWT_SECRET')).toBe(true); + }); + + it('documents the vars this issue called out', () => { + for (const v of [ + 'CORS_ORIGIN', + 'API_URL', + 'SENTRY_DSN', + 'RATE_LIMIT_ABUSE_WINDOW_SECONDS', + 'RATE_LIMIT_ABUSE_THRESHOLD', + 'RATE_LIMIT_LOCKOUT_SECONDS', + ]) { + expect(documented.has(v)).toBe(true); + } + }); + + it('documents every process.env var referenced in backend/src', () => { + const missing = [...referenced].filter(v => !documented.has(v) && !RUNTIME_ONLY.has(v)).sort(); + expect(missing).toEqual([]); + }); +}); diff --git a/backend/src/stellar/stellar.controller.spec.ts b/backend/src/stellar/stellar.controller.spec.ts new file mode 100644 index 0000000..ee2061d --- /dev/null +++ b/backend/src/stellar/stellar.controller.spec.ts @@ -0,0 +1,54 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { StellarController } from './stellar.controller'; +import { StellarAccountNotFoundError, StellarService } from './stellar.service'; + +const VALID = 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ'; + +describe('StellarController (#221)', () => { + let stellar: jest.Mocked>; + let controller: StellarController; + + beforeEach(() => { + stellar = { + getBalance: jest.fn(), + getLatestLedger: jest.fn(), + }; + controller = new StellarController(stellar as unknown as StellarService); + }); + + describe('GET /stellar/ledger', () => { + it('returns the latest ledger sequence', async () => { + stellar.getLatestLedger.mockResolvedValue(52_000_123); + await expect(controller.getLatestLedger()).resolves.toEqual({ sequence: 52_000_123 }); + }); + }); + + describe('GET /stellar/balance/:address', () => { + it('returns the native balance for a funded account', async () => { + stellar.getBalance.mockResolvedValue('99.5000000'); + await expect(controller.getBalance(VALID)).resolves.toEqual({ + address: VALID, + balance: '99.5000000', + }); + expect(stellar.getBalance).toHaveBeenCalledWith(VALID); + }); + + it('rejects a malformed address with 400 before touching Horizon', async () => { + await expect(controller.getBalance('not-an-address')).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect(controller.getBalance('GABC')).rejects.toBeInstanceOf(BadRequestException); + expect(stellar.getBalance).not.toHaveBeenCalled(); + }); + + it('maps an unfunded/nonexistent account to 404', async () => { + stellar.getBalance.mockRejectedValue(new StellarAccountNotFoundError(VALID)); + await expect(controller.getBalance(VALID)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('propagates an unexpected error rather than masking it as 404', async () => { + stellar.getBalance.mockRejectedValue(new Error('Horizon 503')); + await expect(controller.getBalance(VALID)).rejects.toThrow('Horizon 503'); + }); + }); +}); diff --git a/backend/src/stellar/stellar.controller.ts b/backend/src/stellar/stellar.controller.ts new file mode 100644 index 0000000..0497092 --- /dev/null +++ b/backend/src/stellar/stellar.controller.ts @@ -0,0 +1,52 @@ +import { BadRequestException, Controller, Get, NotFoundException, Param } from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { StrKey } from '@stellar/stellar-sdk'; +import { StellarAccountNotFoundError, StellarService } from './stellar.service'; + +/** + * Read-only Stellar/Horizon lookups (#221). `StellarService` was fully wired + * into `AppModule` via `StellarModule` but never reachable — nothing injected + * it and no route exposed it. These endpoints make it usable and add the + * address validation / not-found handling the raw service left to callers. + */ +@ApiTags('Stellar') +@Controller('stellar') +export class StellarController { + constructor(private readonly stellar: StellarService) {} + + @Get('ledger') + @ApiOperation({ summary: 'Latest closed ledger sequence' }) + @ApiResponse({ status: 200, schema: { example: { sequence: 52000000 } } }) + async getLatestLedger(): Promise<{ sequence: number }> { + return { sequence: await this.stellar.getLatestLedger() }; + } + + @Get('balance/:address') + @ApiOperation({ summary: 'Native XLM balance for a Stellar account' }) + @ApiParam({ + name: 'address', + example: 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ', + description: 'Stellar account public key (G… strkey)', + }) + @ApiResponse({ status: 200, schema: { example: { address: 'GA7Q…', balance: '99.5000000' } } }) + @ApiResponse({ status: 400, description: 'Malformed Stellar address' }) + @ApiResponse({ status: 404, description: 'Account not found on the network (unfunded or nonexistent)' }) + async getBalance( + @Param('address') address: string, + ): Promise<{ address: string; balance: string }> { + if (!StrKey.isValidEd25519PublicKey(address)) { + throw new BadRequestException( + 'Not a valid Stellar account public key (expected a G… strkey)', + ); + } + + try { + return { address, balance: await this.stellar.getBalance(address) }; + } catch (err) { + if (err instanceof StellarAccountNotFoundError) { + throw new NotFoundException(err.message); + } + throw err; + } + } +} diff --git a/backend/src/stellar/stellar.module.ts b/backend/src/stellar/stellar.module.ts index a41a0db..8d29751 100644 --- a/backend/src/stellar/stellar.module.ts +++ b/backend/src/stellar/stellar.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { StellarService } from './stellar.service'; import { RpcFailoverService } from './rpc-failover.service'; import { RpcStatusController } from './rpc-status.controller'; +import { StellarController } from './stellar.controller'; @Module({ - controllers: [RpcStatusController], + controllers: [RpcStatusController, StellarController], providers: [StellarService, RpcFailoverService], exports: [StellarService, RpcFailoverService], }) diff --git a/backend/src/stellar/stellar.service.ts b/backend/src/stellar/stellar.service.ts index cfeee7c..74acfa0 100644 --- a/backend/src/stellar/stellar.service.ts +++ b/backend/src/stellar/stellar.service.ts @@ -2,6 +2,27 @@ import { Injectable } from '@nestjs/common'; import { Horizon } from '@stellar/stellar-sdk'; import { RpcFailoverService } from './rpc-failover.service'; +/** + * Thrown by `getBalance` when Horizon reports the account does not exist + * (unfunded or never created). Callers get a typed error to map to a 404 + * instead of a raw Horizon SDK error (#221). + */ +export class StellarAccountNotFoundError extends Error { + constructor(public readonly address: string) { + super(`Stellar account ${address} was not found on the network (unfunded or nonexistent)`); + this.name = 'StellarAccountNotFoundError'; + } +} + +function isHorizonNotFound(error: unknown): boolean { + const e = error as { name?: string; response?: { status?: number }; message?: string }; + return ( + e?.response?.status === 404 || + e?.name === 'NotFoundError' || + /\b404\b|not found|resource missing/i.test(e?.message ?? '') + ); +} + @Injectable() export class StellarService { private server: Horizon.Server; @@ -17,7 +38,15 @@ export class StellarService { async getBalance(address: string): Promise { return this.withFailover(async server => { - const account = await server.loadAccount(address); + let account: Awaited>; + try { + account = await server.loadAccount(address); + } catch (error) { + if (isHorizonNotFound(error)) { + throw new StellarAccountNotFoundError(address); + } + throw error; + } const native = account.balances.find((b: any) => b.asset_type === 'native'); return native?.balance ?? '0'; }); @@ -57,6 +86,12 @@ export class StellarService { } catch (error) { lastError = error as Error; + // A missing account is a definitive answer, not a transport failure — + // don't burn retries on it. + if (error instanceof StellarAccountNotFoundError) { + throw error; + } + if (retryOnFailure && attempt < maxRetries) { // If this wasn't the last attempt, wait a bit before retrying await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1)));