Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions backend/.husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
npx --no-install lint-staged --cwd backend
16 changes: 16 additions & 0 deletions backend/SETUP_INSTRUCTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
52 changes: 52 additions & 0 deletions backend/src/common/redis/redis.module.spec.ts
Original file line number Diff line number Diff line change
@@ -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'),
);
});
});
58 changes: 47 additions & 11 deletions backend/src/common/redis/redis.module.ts
Original file line number Diff line number Diff line change
@@ -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,
],
Expand Down
74 changes: 74 additions & 0 deletions backend/src/env-example-coverage.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const vars = new Set<string>();
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<string> {
const text = readFileSync(ENV_EXAMPLE, 'utf8');
const vars = new Set<string>();
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([]);
});
});
54 changes: 54 additions & 0 deletions backend/src/stellar/stellar.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Pick<StellarService, 'getBalance' | 'getLatestLedger'>>;
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');
});
});
});
52 changes: 52 additions & 0 deletions backend/src/stellar/stellar.controller.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Loading