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
524 changes: 456 additions & 68 deletions package-lock.json

Large diffs are not rendered by default.

20 changes: 17 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,27 @@
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.4.4",
"@prisma/client": "^7.8.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"compression": "^1.8.1",
"helmet": "^8.2.0",
"joi": "^18.2.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/compression": "^1.8.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
Expand All @@ -57,15 +67,19 @@
"json",
"ts"
],
"rootDir": "src",
"rootDir": ".",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"coverageDirectory": "coverage",
"modulePathIgnorePatterns": [
"<rootDir>/.agents",
"<rootDir>/.claude"
],
"testEnvironment": "node"
}
}
16 changes: 15 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import configuration from './config/configuration';
import { envValidationSchema } from './config/env.validation';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
imports: [],
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
validationSchema: envValidationSchema,
validationOptions: {
abortEarly: false,
allowUnknown: false,
},
load: [configuration],
}),
],
controllers: [AppController],
providers: [AppService],
})
Expand Down
124 changes: 124 additions & 0 deletions src/common/filters/http-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';

interface PrismaKnownRequestError {
name: string;
code: string;
}

function isPrismaKnownRequestError(
value: unknown,
): value is PrismaKnownRequestError {
return (
typeof value === 'object' &&
value !== null &&
(value as Record<string, unknown>).name ===
'PrismaClientKnownRequestError' &&
typeof (value as Record<string, unknown>).code === 'string'
);
}

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);

catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const path = request.url;
const method = request.method;

let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message: string | string[] = 'Internal server error';
let code: string | undefined;
let errors: string[] | Record<string, unknown> = {};

if (exception instanceof HttpException) {
status = exception.getStatus();
const responseBody = exception.getResponse();

if (typeof responseBody === 'string') {
message = responseBody;
} else if (
typeof responseBody === 'object' &&
responseBody !== null &&
'message' in responseBody
) {
const body = responseBody as Record<string, unknown>;
const rawMessage = body.message;

if (Array.isArray(rawMessage)) {
message = rawMessage.map((item) => String(item));
errors = rawMessage.map((item) => String(item));
} else if (typeof rawMessage === 'string') {
message = rawMessage;
} else {
message = String(rawMessage);
}

if (typeof body.error === 'string') {
code = body.error.toUpperCase().replace(/\s+/g, '_');
}
}

if (!code) {
switch (status) {
case HttpStatus.BAD_REQUEST:
code = 'BAD_REQUEST';
break;
case HttpStatus.NOT_FOUND:
code = 'NOT_FOUND';
break;
case HttpStatus.CONFLICT:
code = 'CONFLICT';
break;
case HttpStatus.UNAUTHORIZED:
code = 'UNAUTHORIZED';
break;
case HttpStatus.FORBIDDEN:
code = 'FORBIDDEN';
break;
}
}
} else if (isPrismaKnownRequestError(exception)) {
switch (exception.code) {
case 'P2002':
status = HttpStatus.CONFLICT;
message = 'A record with this value already exists';
code = 'DUPLICATE_ENTRY';
break;
case 'P2025':
status = HttpStatus.NOT_FOUND;
message = 'Record not found';
code = 'NOT_FOUND';
break;
default:
status = HttpStatus.INTERNAL_SERVER_ERROR;
message = 'Database error';
code = 'DB_ERROR';
}
}

const logMessage = `${method} ${path} ${status} — ${
Array.isArray(message) ? message.join(', ') : message
}`;
this.logger.error(logMessage);

response.status(status).json({
statusCode: status,
message,
code,
errors,
timestamp: new Date().toISOString(),
path,
});
}
}
38 changes: 38 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
export default () => ({
app: {
nodeEnv: process.env.NODE_ENV,
port: Number(process.env.PORT),
apiPrefix: process.env.API_PREFIX,
corsOrigins: process.env.CORS_ORIGINS,
},
database: {
url: process.env.DATABASE_URL,
directUrl: process.env.DIRECT_URL,
},
supabase: {
url: process.env.SUPABASE_URL,
jwtSecret: process.env.SUPABASE_JWT_SECRET,
serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
},
stellar: {
network: process.env.STELLAR_NETWORK,
horizonUrl: process.env.STELLAR_HORIZON_URL,
rpcUrl: process.env.STELLAR_RPC_URL,
usdcIssuer: process.env.STELLAR_USDC_ISSUER,
networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE,
},
webauthn: {
rpId: process.env.WEBAUTHN_RP_ID,
rpName: process.env.WEBAUTHN_RP_NAME,
origin: process.env.WEBAUTHN_ORIGIN,
},
payments: {
submitTimeoutMs: Number(process.env.PAYMENT_SUBMIT_TIMEOUT_MS),
pollIntervalMs: Number(process.env.PAYMENT_POLL_INTERVAL_MS),
pollMaxAttempts: Number(process.env.PAYMENT_POLL_MAX_ATTEMPTS),
},
throttle: {
ttlMs: Number(process.env.THROTTLE_TTL_MS),
limit: Number(process.env.THROTTLE_LIMIT),
},
});
30 changes: 30 additions & 0 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Joi from 'joi';

export const envValidationSchema = Joi.object({
NODE_ENV: Joi.string().valid('development', 'production', 'test').required(),
PORT: Joi.number().integer().positive().required(),
API_PREFIX: Joi.string().required(),
CORS_ORIGINS: Joi.string().required(),
DATABASE_URL: Joi.string()
.uri({ scheme: ['postgresql', 'postgres'] })
.required(),
DIRECT_URL: Joi.string()
.uri({ scheme: ['postgresql', 'postgres'] })
.required(),
SUPABASE_URL: Joi.string().uri().required(),
SUPABASE_JWT_SECRET: Joi.string().required(),
SUPABASE_SERVICE_ROLE_KEY: Joi.string().required(),
STELLAR_NETWORK: Joi.string().valid('testnet', 'mainnet').required(),
STELLAR_HORIZON_URL: Joi.string().uri().required(),
STELLAR_RPC_URL: Joi.string().uri().required(),
STELLAR_USDC_ISSUER: Joi.string().required(),
STELLAR_NETWORK_PASSPHRASE: Joi.string().required(),
WEBAUTHN_RP_ID: Joi.string().required(),
WEBAUTHN_RP_NAME: Joi.string().required(),
WEBAUTHN_ORIGIN: Joi.string().uri().required(),
PAYMENT_SUBMIT_TIMEOUT_MS: Joi.number().integer().positive().required(),
PAYMENT_POLL_INTERVAL_MS: Joi.number().integer().positive().required(),
PAYMENT_POLL_MAX_ATTEMPTS: Joi.number().integer().positive().required(),
THROTTLE_TTL_MS: Joi.number().integer().positive().required(),
THROTTLE_LIMIT: Joi.number().integer().positive().required(),
}).unknown(true);
64 changes: 61 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,66 @@
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import helmet from 'helmet';
import compression from 'compression';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
const app = await NestFactory.create(AppModule, {
logger: ['log', 'error', 'warn', 'debug'],
});

const configService = app.get(ConfigService);
const nodeEnv = configService.get<string>('app.nodeEnv') ?? 'development';
const port = configService.get<number>('app.port') ?? 3000;
const corsOrigins = configService.get<string>('app.corsOrigins') ?? '';

app.use(helmet());
app.use(compression());

const origins = corsOrigins
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
app.enableCors({ origin: origins, credentials: true });

app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);

app.enableVersioning({ type: VersioningType.URI });

let swaggerEnabled = false;

if (nodeEnv !== 'production') {
swaggerEnabled = true;
const swaggerConfig = new DocumentBuilder()
.setTitle('Ding Payments API')
.setDescription('Self-custodial Stellar P2P payments via NFC')
.setVersion('1.0')
.addBearerAuth()
.build();

const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('/v1/docs', app, document);
}

app.useGlobalFilters(new HttpExceptionFilter());
app.enableShutdownHooks();

await app.listen(port);

Logger.log(`🚀 Server running on http://localhost:${port}/v1`, 'Bootstrap');
if (swaggerEnabled) {
Logger.log(`📚 Swagger docs at http://localhost:${port}/v1/docs`, 'Bootstrap');
}
}
bootstrap();

void bootstrap();
Loading
Loading