diff --git a/.env.example b/.env.example index 8fbe887..54425c9 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index a017695..c9d63d9 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/prisma/migrations/20260729000000_add_maintenance_state/migration.sql b/prisma/migrations/20260729000000_add_maintenance_state/migration.sql new file mode 100644 index 0000000..c91ec98 --- /dev/null +++ b/prisma/migrations/20260729000000_add_maintenance_state/migration.sql @@ -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") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5f4f358..5ca8a8c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 +} diff --git a/src/app.module.ts b/src/app.module.ts index d0b201e..3644223 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/common/http/body-size-limit.spec.ts b/src/common/http/body-size-limit.spec.ts new file mode 100644 index 0000000..68e80d3 --- /dev/null +++ b/src/common/http/body-size-limit.spec.ts @@ -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', + }); + }); +}); diff --git a/src/common/http/body-size-limit.ts b/src/common/http/body-size-limit.ts new file mode 100644 index 0000000..54b003a --- /dev/null +++ b/src/common/http/body-size-limit.ts @@ -0,0 +1,51 @@ +import { HttpStatus } from '@nestjs/common'; +import { + ErrorRequestHandler, + Request, + RequestHandler, + Response, + json, + urlencoded, +} from 'express'; + +type MiddlewareApplication = { + use(...handlers: Array): 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', + }); +}; diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts index 535e8e5..2cfe48e 100644 --- a/src/config/env.validation.spec.ts +++ b/src/config/env.validation.spec.ts @@ -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', () => { @@ -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()); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 843432e..0f7d5af 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -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; @@ -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', @@ -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, diff --git a/src/main.ts b/src/main.ts index 1fb15fd..439d62d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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. @@ -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 @@ -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(); diff --git a/src/maintenance/dto/update-maintenance.dto.ts b/src/maintenance/dto/update-maintenance.dto.ts new file mode 100644 index 0000000..8f59a17 --- /dev/null +++ b/src/maintenance/dto/update-maintenance.dto.ts @@ -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; +} diff --git a/src/maintenance/maintenance-admin.guard.spec.ts b/src/maintenance/maintenance-admin.guard.spec.ts new file mode 100644 index 0000000..83d6206 --- /dev/null +++ b/src/maintenance/maintenance-admin.guard.spec.ts @@ -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, + ); + }); +}); diff --git a/src/maintenance/maintenance-admin.guard.ts b/src/maintenance/maintenance-admin.guard.ts new file mode 100644 index 0000000..10ddac5 --- /dev/null +++ b/src/maintenance/maintenance-admin.guard.ts @@ -0,0 +1,42 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { timingSafeEqual } from 'crypto'; +import { Request } from 'express'; + +@Injectable() +export class MaintenanceAdminGuard implements CanActivate { + constructor(private readonly config: ConfigService) {} + + canActivate(context: ExecutionContext): boolean { + const configured = this.config.get('MAINTENANCE_ADMIN_SECRET', ''); + const supplied = context + .switchToHttp() + .getRequest().headers['x-maintenance-secret']; + + if ( + !configured || + typeof supplied !== 'string' || + !this.secretsMatch(configured, supplied) + ) { + throw new UnauthorizedException( + 'A valid maintenance administrator secret is required', + ); + } + + return true; + } + + private secretsMatch(expected: string, actual: string): boolean { + const expectedBuffer = Buffer.from(expected); + const actualBuffer = Buffer.from(actual); + return ( + expectedBuffer.length === actualBuffer.length && + timingSafeEqual(expectedBuffer, actualBuffer) + ); + } +} diff --git a/src/maintenance/maintenance.controller.ts b/src/maintenance/maintenance.controller.ts new file mode 100644 index 0000000..9e6e038 --- /dev/null +++ b/src/maintenance/maintenance.controller.ts @@ -0,0 +1,51 @@ +import { Body, Controller, Get, Patch, Req, UseGuards } from '@nestjs/common'; +import { + ApiHeader, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { Request } from 'express'; +import { ApiKeyContext } from '../api-keys/domain/api-key.model'; +import { + MaintenanceStatusDto, + UpdateMaintenanceDto, +} from './dto/update-maintenance.dto'; +import { MaintenanceAdminGuard } from './maintenance-admin.guard'; +import { AllowDuringMaintenance } from './maintenance.decorator'; +import { MaintenanceService } from './maintenance.service'; + +@ApiTags('maintenance') +@Controller('maintenance') +export class MaintenanceController { + constructor(private readonly maintenance: MaintenanceService) {} + + @Get() + @ApiOperation({ summary: 'Get the current maintenance mode status' }) + @ApiResponse({ status: 200, type: MaintenanceStatusDto }) + getStatus(): Promise { + return this.maintenance.getStatus(); + } + + @Patch() + @AllowDuringMaintenance() + @UseGuards(MaintenanceAdminGuard) + @ApiHeader({ + name: 'X-Maintenance-Secret', + required: true, + description: 'Maintenance administrator shared secret', + }) + @ApiOperation({ summary: 'Enable or disable maintenance mode' }) + @ApiResponse({ status: 200, type: MaintenanceStatusDto }) + @ApiResponse({ status: 400, description: 'Invalid maintenance settings' }) + @ApiResponse({ status: 401, description: 'Missing or invalid credentials' }) + updateStatus( + @Body() update: UpdateMaintenanceDto, + @Req() request: Request & { apiKeyContext?: ApiKeyContext }, + ): Promise { + return this.maintenance.updateStatus( + update, + request.apiKeyContext?.apiKey.id ?? 'internal', + ); + } +} diff --git a/src/maintenance/maintenance.decorator.ts b/src/maintenance/maintenance.decorator.ts new file mode 100644 index 0000000..4040a1f --- /dev/null +++ b/src/maintenance/maintenance.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ALLOW_DURING_MAINTENANCE = 'allowDuringMaintenance'; + +/** Allows an exceptional mutating route, such as the maintenance toggle. */ +export const AllowDuringMaintenance = () => + SetMetadata(ALLOW_DURING_MAINTENANCE, true); diff --git a/src/maintenance/maintenance.guard.spec.ts b/src/maintenance/maintenance.guard.spec.ts new file mode 100644 index 0000000..f43aff6 --- /dev/null +++ b/src/maintenance/maintenance.guard.spec.ts @@ -0,0 +1,62 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import { MaintenanceGuard } from './maintenance.guard'; + +function context(method: string) { + const response = { setHeader: jest.fn() }; + return { + response, + value: { + switchToHttp: () => ({ + getRequest: () => ({ method }), + getResponse: () => response, + }), + getHandler: () => function handler() {}, + getClass: () => class Controller {}, + } as any, + }; +} + +describe('MaintenanceGuard', () => { + const maintenance = { getStatus: jest.fn() }; + const reflector = { getAllAndOverride: jest.fn() }; + const guard = new MaintenanceGuard(maintenance as any, reflector as any); + + beforeEach(() => jest.clearAllMocks()); + + it('allows read-only routes without querying persistence', async () => { + const { value } = context('GET'); + await expect(guard.canActivate(value)).resolves.toBe(true); + expect(maintenance.getStatus).not.toHaveBeenCalled(); + }); + + it('allows mutating routes when maintenance mode is disabled', async () => { + reflector.getAllAndOverride.mockReturnValue(false); + maintenance.getStatus.mockResolvedValue({ enabled: false }); + const { value } = context('POST'); + await expect(guard.canActivate(value)).resolves.toBe(true); + }); + + it('blocks mutating routes with 503 and Retry-After when enabled', async () => { + reflector.getAllAndOverride.mockReturnValue(false); + maintenance.getStatus.mockResolvedValue({ + enabled: true, + message: 'Planned maintenance', + retryAfterSeconds: 60, + }); + const { value, response } = context('PATCH'); + + await expect(guard.canActivate(value)).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + expect(response.setHeader).toHaveBeenCalledWith('Retry-After', '60'); + }); + + it('fails closed when maintenance state cannot be read', async () => { + reflector.getAllAndOverride.mockReturnValue(false); + maintenance.getStatus.mockRejectedValue(new Error('database unavailable')); + const { value } = context('DELETE'); + await expect(guard.canActivate(value)).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + }); +}); diff --git a/src/maintenance/maintenance.guard.ts b/src/maintenance/maintenance.guard.ts new file mode 100644 index 0000000..50d22f2 --- /dev/null +++ b/src/maintenance/maintenance.guard.ts @@ -0,0 +1,63 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request, Response } from 'express'; +import { ALLOW_DURING_MAINTENANCE } from './maintenance.decorator'; +import { MaintenanceService } from './maintenance.service'; + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +@Injectable() +export class MaintenanceGuard implements CanActivate { + private readonly logger = new Logger(MaintenanceGuard.name); + + constructor( + private readonly maintenance: MaintenanceService, + private readonly reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + if (SAFE_METHODS.has(request.method.toUpperCase())) return true; + + const allowed = this.reflector.getAllAndOverride( + ALLOW_DURING_MAINTENANCE, + [context.getHandler(), context.getClass()], + ); + if (allowed) return true; + + let status; + try { + status = await this.maintenance.getStatus(); + } catch (error) { + this.logger.error( + 'Unable to read maintenance state; rejecting mutating request', + error instanceof Error ? error.stack : undefined, + ); + throw new ServiceUnavailableException( + 'Service is temporarily unavailable', + ); + } + + if (!status.enabled) return true; + + const response = context.switchToHttp().getResponse(); + if (status.retryAfterSeconds) { + response.setHeader('Retry-After', status.retryAfterSeconds.toString()); + } + + throw new ServiceUnavailableException({ + statusCode: 503, + error: 'Service Unavailable', + message: + status.message || + 'Service is temporarily unavailable for maintenance', + maintenance: true, + }); + } +} diff --git a/src/maintenance/maintenance.module.ts b/src/maintenance/maintenance.module.ts new file mode 100644 index 0000000..39c3c63 --- /dev/null +++ b/src/maintenance/maintenance.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { MaintenanceAdminGuard } from './maintenance-admin.guard'; +import { MaintenanceController } from './maintenance.controller'; +import { MaintenanceGuard } from './maintenance.guard'; +import { MaintenanceService } from './maintenance.service'; + +@Module({ + controllers: [MaintenanceController], + providers: [MaintenanceService, MaintenanceGuard, MaintenanceAdminGuard], + exports: [MaintenanceService, MaintenanceGuard], +}) +export class MaintenanceModule {} diff --git a/src/maintenance/maintenance.service.spec.ts b/src/maintenance/maintenance.service.spec.ts new file mode 100644 index 0000000..d8a32e1 --- /dev/null +++ b/src/maintenance/maintenance.service.spec.ts @@ -0,0 +1,68 @@ +import { Test } from '@nestjs/testing'; +import { PrismaService } from '../prisma/prisma.service'; +import { MaintenanceService } from './maintenance.service'; + +describe('MaintenanceService', () => { + const prisma = { + maintenanceState: { + findUnique: jest.fn(), + upsert: jest.fn(), + }, + }; + let service: MaintenanceService; + + beforeEach(async () => { + jest.clearAllMocks(); + const moduleRef = await Test.createTestingModule({ + providers: [ + MaintenanceService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile(); + service = moduleRef.get(MaintenanceService); + }); + + it('defaults to disabled when no persisted state exists', async () => { + prisma.maintenanceState.findUnique.mockResolvedValue(null); + + await expect(service.getStatus()).resolves.toEqual({ + enabled: false, + message: null, + retryAfterSeconds: null, + enabledAt: null, + updatedAt: null, + }); + }); + + it('persists and returns enabled maintenance state', async () => { + const state = { + enabled: true, + message: 'Ledger upgrade', + retryAfterSeconds: 120, + enabledAt: new Date('2026-07-29T10:00:00.000Z'), + updatedAt: new Date('2026-07-29T10:00:00.000Z'), + }; + prisma.maintenanceState.upsert.mockResolvedValue(state); + + await expect( + service.updateStatus( + { enabled: true, message: 'Ledger upgrade', retryAfterSeconds: 120 }, + 'api-key-id', + ), + ).resolves.toEqual(state); + expect(prisma.maintenanceState.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'global' }, + create: expect.objectContaining({ updatedBy: 'api-key-id' }), + update: expect.objectContaining({ updatedBy: 'api-key-id' }), + }), + ); + }); + + it('propagates persistence failures', async () => { + prisma.maintenanceState.findUnique.mockRejectedValue( + new Error('database unavailable'), + ); + await expect(service.getStatus()).rejects.toThrow('database unavailable'); + }); +}); diff --git a/src/maintenance/maintenance.service.ts b/src/maintenance/maintenance.service.ts new file mode 100644 index 0000000..66721ed --- /dev/null +++ b/src/maintenance/maintenance.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { + MaintenanceStatusDto, + UpdateMaintenanceDto, +} from './dto/update-maintenance.dto'; + +const GLOBAL_MAINTENANCE_ID = 'global'; + +@Injectable() +export class MaintenanceService { + constructor(private readonly prisma: PrismaService) {} + + async getStatus(): Promise { + const state = await this.prisma.maintenanceState.findUnique({ + where: { id: GLOBAL_MAINTENANCE_ID }, + }); + + if (!state) { + return { + enabled: false, + message: null, + retryAfterSeconds: null, + enabledAt: null, + updatedAt: null, + }; + } + + return { + enabled: state.enabled, + message: state.message, + retryAfterSeconds: state.retryAfterSeconds, + enabledAt: state.enabledAt, + updatedAt: state.updatedAt, + }; + } + + async updateStatus( + update: UpdateMaintenanceDto, + updatedBy: string, + ): Promise { + const state = await this.prisma.maintenanceState.upsert({ + where: { id: GLOBAL_MAINTENANCE_ID }, + create: { + id: GLOBAL_MAINTENANCE_ID, + enabled: update.enabled, + message: update.message ?? null, + retryAfterSeconds: update.retryAfterSeconds ?? null, + enabledAt: update.enabled ? new Date() : null, + updatedBy, + }, + update: { + enabled: update.enabled, + message: update.message ?? null, + retryAfterSeconds: update.retryAfterSeconds ?? null, + enabledAt: update.enabled ? new Date() : null, + updatedBy, + }, + }); + + return { + enabled: state.enabled, + message: state.message, + retryAfterSeconds: state.retryAfterSeconds, + enabledAt: state.enabledAt, + updatedAt: state.updatedAt, + }; + } +}