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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ DATABASE_URL=postgresql://user:password@localhost:5432/mux_db?sslmode=require
# ------------------------------------------------------------
PORT=3000

# Maximum JSON/form request body size in bytes (default: 102400 / 100 KiB).
# Requests above this limit receive HTTP 413.
JSON_BODY_LIMIT_BYTES=102400

# Shared secret required in X-Maintenance-Secret when toggling maintenance mode.
# Leave unset to disable remote maintenance-mode changes.
MAINTENANCE_ADMIN_SECRET=

# ------------------------------------------------------------
# Wallet Encryption
# Required: Secret used to derive the AES-256-GCM encryption key
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,43 @@ It handles wallet creation, transaction orchestration, fee sponsorship, and on-c

All routes below are served under the `/v1` prefix (e.g. `GET /v1/health`). See [docs/API-VERSIONING.md](docs/API-VERSIONING.md) for the versioning strategy.

### Request body size

JSON and URL-encoded request bodies are limited to 100 KiB by default. Set
`JSON_BODY_LIMIT_BYTES` to a value from 1 byte through 10 MiB to change the
limit. Requests over the configured limit return `413 Payload Too Large`:

```json
{
"statusCode": 413,
"error": "Payload Too Large",
"message": "Request body exceeds the maximum allowed size"
}
```

### Maintenance mode

Maintenance mode is persisted in PostgreSQL and shared by every API instance.
While enabled, `POST`, `PUT`, `PATCH`, and `DELETE` routes return `503 Service
Unavailable`; `GET`, `HEAD`, and `OPTIONS` remain available. A configured retry
delay is returned in the `Retry-After` header.

Authenticated callers can inspect `GET /v1/maintenance`. To change the state,
send `PATCH /v1/maintenance` with normal API-key authentication plus the
`X-Maintenance-Secret` header matching `MAINTENANCE_ADMIN_SECRET`:

```json
{
"enabled": true,
"message": "Scheduled ledger maintenance",
"retryAfterSeconds": 300
}
```

The maintenance endpoint itself remains available while maintenance mode is on
so an authorized operator can disable it. If the persisted state cannot be read,
mutating requests fail closed with `503 Service Unavailable`.

### Health & Monitoring

#### `GET /health`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- Persist the global maintenance switch so all application instances agree.
CREATE TABLE "MaintenanceState" (
"id" TEXT NOT NULL DEFAULT 'global',
"enabled" BOOLEAN NOT NULL DEFAULT false,
"message" TEXT,
"retryAfterSeconds" INTEGER,
"enabledAt" TIMESTAMP(3),
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "MaintenanceState_pkey" PRIMARY KEY ("id")
);
13 changes: 13 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -971,3 +971,16 @@ model TransactionExportJob {
@@index([createdAt])
@@index([expiresAt])
}


/// Global operational switch used to reject mutating HTTP routes during maintenance.
model MaintenanceState {
id String @id @default("global")
enabled Boolean @default(false)
message String?
retryAfterSeconds Int?
enabledAt DateTime?
updatedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
4 changes: 4 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ import { LatencySloInterceptor } from './common/slo/latency-slo.interceptor';
provide: APP_GUARD,
useClass: ApiKeyGuard,
},
{
provide: APP_GUARD,
useClass: MaintenanceGuard,
},
{
provide: APP_GUARD,
useClass: RateLimitGuard,
Expand Down
30 changes: 30 additions & 0 deletions src/common/http/body-size-limit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import express from 'express';
import request from 'supertest';
import { configureBodySizeLimit } from './body-size-limit';

describe('configureBodySizeLimit', () => {
function createApp(limitBytes: number) {
const app = express();
configureBodySizeLimit(app, limitBytes);
app.post('/public', (req, res) => res.status(201).json(req.body));
return app;
}

it('accepts a JSON request below the configured limit', async () => {
await request(createApp(128))
.post('/public')
.send({ value: 'small' })
.expect(201, { value: 'small' });
});

it('returns a consistent 413 response when JSON exceeds the limit', async () => {
await request(createApp(32))
.post('/public')
.send({ value: 'x'.repeat(64) })
.expect(413, {
statusCode: 413,
error: 'Payload Too Large',
message: 'Request body exceeds the maximum allowed size',
});
});
});
51 changes: 51 additions & 0 deletions src/common/http/body-size-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { HttpStatus } from '@nestjs/common';
import {
ErrorRequestHandler,
Request,
RequestHandler,
Response,
json,
urlencoded,
} from 'express';

type MiddlewareApplication = {
use(...handlers: Array<RequestHandler | ErrorRequestHandler>): unknown;
};

/**
* Installs the request body parsers with an explicit byte limit.
*
* Nest's implicit parser must be disabled when the application is created so
* this is the only parser that consumes the request stream.
*/
export function configureBodySizeLimit(
app: MiddlewareApplication,
limitBytes: number,
): void {
app.use(
json({ limit: limitBytes }) as RequestHandler,
urlencoded({ extended: true, limit: limitBytes }) as RequestHandler,
payloadTooLargeHandler,
);
}

const payloadTooLargeHandler: ErrorRequestHandler = (
error: Error & { type?: string; status?: number },
_request: Request,
response: Response,
next,
) => {
if (
error.type !== 'entity.too.large' &&
error.status !== HttpStatus.PAYLOAD_TOO_LARGE
) {
next(error);
return;
}

response.status(HttpStatus.PAYLOAD_TOO_LARGE).json({
statusCode: HttpStatus.PAYLOAD_TOO_LARGE,
error: 'Payload Too Large',
message: 'Request body exceeds the maximum allowed size',
});
};
22 changes: 22 additions & 0 deletions src/config/env.validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ describe('validateEnv()', () => {
it('defaults to 3000 when not set', () => {
const result = validateEnv(env());
expect(result.PORT).toBe(3000);
expect(result.JSON_BODY_LIMIT_BYTES).toBe(102_400);
expect(result.MAINTENANCE_ADMIN_SECRET).toBe('');
});

it('accepts a valid port number', () => {
Expand All @@ -130,6 +132,26 @@ describe('validateEnv()', () => {
});
});

describe('JSON_BODY_LIMIT_BYTES', () => {
it('defaults to 100 KiB', () => {
expect(validateEnv(env()).JSON_BODY_LIMIT_BYTES).toBe(102_400);
});

it('accepts a custom byte limit', () => {
expect(
validateEnv(env({ JSON_BODY_LIMIT_BYTES: '1048576' }))
.JSON_BODY_LIMIT_BYTES,
).toBe(1_048_576);
});

it('rejects values above 10 MiB', () => {
expectError(
env({ JSON_BODY_LIMIT_BYTES: '10485761' }),
'JSON_BODY_LIMIT_BYTES must be <= 10485760',
);
});
});

describe('AUTH_RATE_LIMIT_MAX', () => {
it('defaults to 10', () => {
const result = validateEnv(env());
Expand Down
13 changes: 13 additions & 0 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface EnvViolation {
export interface ValidatedEnv {
DATABASE_URL: string;
PORT: number;
JSON_BODY_LIMIT_BYTES: number;
MAINTENANCE_ADMIN_SECRET: string;
WALLET_ENCRYPTION_KEY: string;
STELLAR_HORIZON_URL: string;
BALANCE_STALE_THRESHOLD_MS: number;
Expand Down Expand Up @@ -197,9 +199,18 @@ export function validateEnv(env: NodeJS.ProcessEnv): ValidatedEnv {
'STELLAR_HORIZON_URL',
violations,
);
const MAINTENANCE_ADMIN_SECRET =
env.MAINTENANCE_ADMIN_SECRET?.trim() ?? '';

// ── Optional numeric fields ───────────────────────────────────────────────
const PORT = optionalInt(env, 'PORT', 3000, { min: 1, max: 65535 }, violations);
const JSON_BODY_LIMIT_BYTES = optionalInt(
env,
'JSON_BODY_LIMIT_BYTES',
102_400,
{ min: 1, max: 10_485_760 },
violations,
);
const BALANCE_STALE_THRESHOLD_MS = optionalInt(
env,
'BALANCE_STALE_THRESHOLD_MS',
Expand Down Expand Up @@ -326,6 +337,8 @@ export function validateEnv(env: NodeJS.ProcessEnv): ValidatedEnv {
return {
DATABASE_URL,
PORT,
JSON_BODY_LIMIT_BYTES,
MAINTENANCE_ADMIN_SECRET,
WALLET_ENCRYPTION_KEY,
STELLAR_HORIZON_URL,
BALANCE_STALE_THRESHOLD_MS,
Expand Down
13 changes: 8 additions & 5 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import requestLogger from './common/middleware/request-logging.middleware';
import { configureBodySizeLimit } from './common/http/body-size-limit';
import { validateEnv } from './config/env.validation';

/**
* Parses the CORS_ALLOWED_ORIGINS env var into an array of allowed origins.
Expand All @@ -20,9 +22,11 @@ async function bootstrap() {
const logger = new Logger('Bootstrap');

// Validate all required environment variables before anything else starts.
validateEnv(process.env);
const env = validateEnv(process.env);

const app = await NestFactory.create(AppModule);
const app = await NestFactory.create(AppModule, { bodyParser: false });

configureBodySizeLimit(app, env.JSON_BODY_LIMIT_BYTES);

// Configure CORS with credentials support
// Only allow credentials when explicitly whitelisted origins are used
Expand Down Expand Up @@ -62,9 +66,8 @@ async function bootstrap() {
// so in-flight requests can finish and connections (Prisma, etc.) close cleanly.
app.enableShutdownHooks();

const port = process.env.PORT ?? 3000;
await app.listen(port);
logger.log(`Application listening on port ${port}`);
await app.listen(env.PORT);
logger.log(`Application listening on port ${env.PORT}`);
}

bootstrap();
53 changes: 53 additions & 0 deletions src/maintenance/dto/update-maintenance.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';

export class UpdateMaintenanceDto {
@ApiProperty({ description: 'Whether mutating API routes are unavailable' })
@IsBoolean()
enabled: boolean;

@ApiPropertyOptional({
description: 'Safe, user-facing maintenance explanation',
maxLength: 500,
})
@IsOptional()
@IsString()
@MaxLength(500)
message?: string;

@ApiPropertyOptional({
description: 'Suggested delay before clients retry, in seconds',
minimum: 1,
maximum: 86400,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(86_400)
retryAfterSeconds?: number;
}

export class MaintenanceStatusDto {
@ApiProperty()
enabled: boolean;

@ApiProperty({ nullable: true })
message: string | null;

@ApiProperty({ nullable: true })
retryAfterSeconds: number | null;

@ApiProperty({ nullable: true, type: String, format: 'date-time' })
enabledAt: Date | null;

@ApiProperty({ nullable: true, type: String, format: 'date-time' })
updatedAt: Date | null;
}
28 changes: 28 additions & 0 deletions src/maintenance/maintenance-admin.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { UnauthorizedException } from '@nestjs/common';
import { MaintenanceAdminGuard } from './maintenance-admin.guard';

function context(secret?: string) {
return {
switchToHttp: () => ({
getRequest: () => ({ headers: { 'x-maintenance-secret': secret } }),
}),
} as any;
}

describe('MaintenanceAdminGuard', () => {
it('allows a caller with the configured secret', () => {
const guard = new MaintenanceAdminGuard({
get: () => 'configured-secret',
} as any);
expect(guard.canActivate(context('configured-secret'))).toBe(true);
});

it('rejects a caller with an invalid secret', () => {
const guard = new MaintenanceAdminGuard({
get: () => 'configured-secret',
} as any);
expect(() => guard.canActivate(context('wrong-secret'))).toThrow(
UnauthorizedException,
);
});
});
Loading
Loading