From e97a6b3acdf9b2085cb35a2a969a1e464535441a Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Tue, 24 Mar 2026 21:46:13 +0100 Subject: [PATCH 1/9] Add CloudWatch log aggregation and correlation IDs --- README.md | 4 + apps/api/.env.example | 5 + apps/api/src/app.controller.ts | 9 + apps/api/src/app.module.ts | 9 +- apps/api/src/app.service.ts | 9 + apps/api/src/health/health.controller.ts | 9 + apps/api/src/main.ts | 17 +- apps/api/src/observability/index.ts | 5 + apps/api/src/observability/logger.spec.ts | 52 +++ apps/api/src/observability/logger.ts | 151 ++++++++ .../observability/logging-exception.filter.ts | 36 ++ .../request-context.middleware.ts | 39 ++ apps/api/src/observability/request-context.ts | 42 +++ .../request-logging.interceptor.ts | 45 +++ apps/api/src/treasury/treasury.controller.ts | 10 + apps/api/src/treasury/treasury.service.ts | 31 +- .../src/app/dashboard/audit-logs/page.tsx | 338 +++++++++++++----- docs/observability/README.md | 65 ++++ docs/observability/cloudwatch-alarms.json | 40 +++ docs/observability/cloudwatch-dashboard.json | 78 ++++ .../cloudwatch-metric-filters.json | 47 +++ packages/anchor-service/src/index.ts | 2 + packages/anchor-service/src/logger.ts | 173 +++++++++ .../anchor-service/src/request-context.ts | 42 +++ packages/compliance-engine/src/index.ts | 2 + packages/compliance-engine/src/logger.ts | 173 +++++++++ .../compliance-engine/src/request-context.ts | 42 +++ packages/escrow/src/index.ts | 2 + packages/escrow/src/logger.ts | 173 +++++++++ packages/escrow/src/request-context.ts | 42 +++ packages/payments-engine/src/index.ts | 2 + packages/payments-engine/src/logger.ts | 173 +++++++++ .../payments-engine/src/request-context.ts | 42 +++ packages/sdk-js/src/index.ts | 2 + packages/sdk-js/src/logger.ts | 173 +++++++++ packages/sdk-js/src/request-context.ts | 42 +++ packages/subscriptions/src/index.ts | 2 + packages/subscriptions/src/logger.ts | 173 +++++++++ packages/subscriptions/src/request-context.ts | 42 +++ scripts/observability/deploy-cloudwatch.ps1 | 77 ++++ 40 files changed, 2334 insertions(+), 86 deletions(-) create mode 100644 apps/api/src/observability/index.ts create mode 100644 apps/api/src/observability/logger.spec.ts create mode 100644 apps/api/src/observability/logger.ts create mode 100644 apps/api/src/observability/logging-exception.filter.ts create mode 100644 apps/api/src/observability/request-context.middleware.ts create mode 100644 apps/api/src/observability/request-context.ts create mode 100644 apps/api/src/observability/request-logging.interceptor.ts create mode 100644 docs/observability/README.md create mode 100644 docs/observability/cloudwatch-alarms.json create mode 100644 docs/observability/cloudwatch-dashboard.json create mode 100644 docs/observability/cloudwatch-metric-filters.json create mode 100644 packages/anchor-service/src/logger.ts create mode 100644 packages/anchor-service/src/request-context.ts create mode 100644 packages/compliance-engine/src/logger.ts create mode 100644 packages/compliance-engine/src/request-context.ts create mode 100644 packages/escrow/src/logger.ts create mode 100644 packages/escrow/src/request-context.ts create mode 100644 packages/payments-engine/src/logger.ts create mode 100644 packages/payments-engine/src/request-context.ts create mode 100644 packages/sdk-js/src/logger.ts create mode 100644 packages/sdk-js/src/request-context.ts create mode 100644 packages/subscriptions/src/logger.ts create mode 100644 packages/subscriptions/src/request-context.ts create mode 100644 scripts/observability/deploy-cloudwatch.ps1 diff --git a/README.md b/README.md index 5bf727d..97594b0 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,10 @@ stellar-pay/ - **Phase 3**: Anchor Integration (SEP-24) & Compliance Hooks. - **Phase 4**: Admin Dashboard & Event/Webhook Streaming. +## Observability + +Structured JSON logging, request correlation IDs, and CloudWatch dashboard assets live under [docs/observability/README.md](docs/observability/README.md). Use `scripts/observability/deploy-cloudwatch.ps1` to provision the dashboard, metric filters, and alarms for a shared `/stellar-pay/...` log group. + --- ## Contributing diff --git a/apps/api/.env.example b/apps/api/.env.example index f10135c..f874e16 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -17,3 +17,8 @@ DATABASE_URL=postgresql://user:password@localhost:5432/stellar_pay # Redis (for when implemented) REDIS_URL=redis://localhost:6379 + +# Observability +LOG_LEVEL=info +CLOUDWATCH_LOG_GROUP=/stellar-pay/production +CLOUDWATCH_DASHBOARD_NAME=stellar-pay-observability diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts index 8f890d6..3c4b1cb 100644 --- a/apps/api/src/app.controller.ts +++ b/apps/api/src/app.controller.ts @@ -1,6 +1,11 @@ import { Controller, Get } from '@nestjs/common'; import { AppService } from './app.service'; import { Public } from './auth/decorators/public.decorator'; +import { apiLogger } from './observability'; + +const controllerLogger = apiLogger.child({ + controller: 'AppController', +}); @Controller() export class AppController { @@ -9,6 +14,10 @@ export class AppController { @Get() @Public() getHello(): string { + controllerLogger.info('Processing hello endpoint', { + event: 'hello_endpoint_requested', + }); + return this.appService.getHello(); } } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 72888f2..cab57aa 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { ThrottlerModule } from '@nestjs/throttler'; import { AppController } from './app.controller'; @@ -8,6 +8,7 @@ import { TreasuryModule } from './treasury/treasury.module'; import { AuthModule } from './auth/auth.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { ThrottlerRedisGuard } from './rate-limiter/guards/throttler-redis.guard'; +import { RequestContextMiddleware } from './observability'; @Module({ imports: [ @@ -36,4 +37,8 @@ import { ThrottlerRedisGuard } from './rate-limiter/guards/throttler-redis.guard }, ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(RequestContextMiddleware).forRoutes('*'); + } +} diff --git a/apps/api/src/app.service.ts b/apps/api/src/app.service.ts index 927d7cc..0a9e51e 100644 --- a/apps/api/src/app.service.ts +++ b/apps/api/src/app.service.ts @@ -1,8 +1,17 @@ import { Injectable } from '@nestjs/common'; +import { apiLogger } from './observability'; + +const serviceLogger = apiLogger.child({ + serviceContext: 'AppService', +}); @Injectable() export class AppService { getHello(): string { + serviceLogger.debug('Returning hello payload', { + event: 'hello_payload_returned', + }); + return 'Hello World!'; } } diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts index e5ffdf2..c6faf4b 100644 --- a/apps/api/src/health/health.controller.ts +++ b/apps/api/src/health/health.controller.ts @@ -5,6 +5,11 @@ import { DatabaseHealthIndicator } from './indicators/database.health'; import { RedisHealthIndicator } from './indicators/redis.health'; import { BlockchainRpcHealthIndicator } from './indicators/blockchain-rpc.health'; import { TreasuryWalletHealthIndicator } from './indicators/treasury-wallet.health'; +import { apiLogger } from '../observability'; + +const controllerLogger = apiLogger.child({ + controller: 'HealthController', +}); @Controller('health') export class HealthController { @@ -20,6 +25,10 @@ export class HealthController { @Public() @HealthCheck() check(): Promise { + controllerLogger.info('Running health checks', { + event: 'health_check_requested', + }); + return this.health.check([ () => this.database.isHealthy('database'), () => this.redis.isHealthy('redis'), diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index f76bc8d..e4fca82 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,8 +1,21 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { apiLogger, LoggingExceptionFilter, RequestLoggingInterceptor } from './observability'; async function bootstrap() { - const app = await NestFactory.create(AppModule); - await app.listen(process.env.PORT ?? 3000); + const app = await NestFactory.create(AppModule, { + logger: false, + }); + + app.useGlobalInterceptors(new RequestLoggingInterceptor()); + app.useGlobalFilters(new LoggingExceptionFilter()); + + const port = Number(process.env.PORT ?? 3000); + await app.listen(port); + + apiLogger.info('API bootstrap completed', { + event: 'api_bootstrap_completed', + port, + }); } bootstrap(); diff --git a/apps/api/src/observability/index.ts b/apps/api/src/observability/index.ts new file mode 100644 index 0000000..41a0453 --- /dev/null +++ b/apps/api/src/observability/index.ts @@ -0,0 +1,5 @@ +export * from './logger'; +export * from './logging-exception.filter'; +export * from './request-context'; +export * from './request-context.middleware'; +export * from './request-logging.interceptor'; diff --git a/apps/api/src/observability/logger.spec.ts b/apps/api/src/observability/logger.spec.ts new file mode 100644 index 0000000..05d20ba --- /dev/null +++ b/apps/api/src/observability/logger.spec.ts @@ -0,0 +1,52 @@ +import { StructuredLogger } from './logger'; +import { runWithRequestContext } from './request-context'; + +describe('StructuredLogger', () => { + const originalLogLevel = process.env.LOG_LEVEL; + + afterEach(() => { + process.env.LOG_LEVEL = originalLogLevel; + jest.restoreAllMocks(); + }); + + it('emits structured JSON with the active correlation id', () => { + process.env.LOG_LEVEL = 'debug'; + + const writeSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + const logger = new StructuredLogger({ + serviceName: 'test-service', + }); + + runWithRequestContext({ correlationId: 'corr-123' }, () => { + logger.info('test message', { + event: 'test_event', + route: '/health', + }); + }); + + expect(writeSpy).toHaveBeenCalledTimes(1); + + const payload = JSON.parse(writeSpy.mock.calls[0][0] as string); + expect(payload).toMatchObject({ + service: 'test-service', + message: 'test message', + event: 'test_event', + route: '/health', + correlationId: 'corr-123', + level: 'info', + }); + }); + + it('respects the configured log level', () => { + process.env.LOG_LEVEL = 'error'; + + const writeSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + const logger = new StructuredLogger({ + serviceName: 'test-service', + }); + + logger.info('should not be written'); + + expect(writeSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/observability/logger.ts b/apps/api/src/observability/logger.ts new file mode 100644 index 0000000..9b92f4e --- /dev/null +++ b/apps/api/src/observability/logger.ts @@ -0,0 +1,151 @@ +import { getCorrelationId, getRequestContext } from './request-context'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export interface StructuredLogEntry { + timestamp: string; + level: LogLevel; + service: string; + message: string; + environment: string; + correlationId?: string; + error?: { + name: string; + message: string; + stack?: string; + }; + [key: string]: unknown; +} + +export type LogMetadata = Record; + +interface LoggerOptions { + serviceName: string; + defaults?: LogMetadata; +} + +export class StructuredLogger { + constructor(private readonly options: LoggerOptions) {} + + child(defaults: LogMetadata): StructuredLogger { + return new StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults, + }, + }); + } + + debug(message: string, metadata: LogMetadata = {}): void { + this.write('debug', message, metadata); + } + + info(message: string, metadata: LogMetadata = {}): void { + this.write('info', message, metadata); + } + + warn(message: string, metadata: LogMetadata = {}): void { + this.write('warn', message, metadata); + } + + error(message: string, metadata: LogMetadata = {}): void { + this.write('error', message, metadata); + } + + private write(level: LogLevel, message: string, metadata: LogMetadata): void { + if (!shouldLog(level)) { + return; + } + + const context = getRequestContext(); + const entry: StructuredLogEntry = { + timestamp: new Date().toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? 'development', + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata), + }; + + Object.keys(entry).forEach((key) => { + if (entry[key] === undefined) { + delete entry[key]; + } + }); + + const payload = JSON.stringify(entry); + + if (level === 'error') { + console.error(payload); + return; + } + + if (level === 'warn') { + console.warn(payload); + return; + } + + console.log(payload); + } +} + +function shouldLog(level: LogLevel): boolean { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} + +function parseLogLevel(value: string | undefined): LogLevel { + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + return value; + } + + return 'info'; +} + +function serializeMetadata(metadata: LogMetadata): LogMetadata { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === undefined) { + return result; + } + + if (key === 'error') { + result.error = serializeError(value); + return result; + } + + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + + result[key] = value; + return result; + }, {}); +} + +function serializeError(error: unknown): StructuredLogEntry['error'] | unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return error; +} + +export const apiLogger = new StructuredLogger({ + serviceName: 'stellar-pay-api', +}); diff --git a/apps/api/src/observability/logging-exception.filter.ts b/apps/api/src/observability/logging-exception.filter.ts new file mode 100644 index 0000000..e8e3925 --- /dev/null +++ b/apps/api/src/observability/logging-exception.filter.ts @@ -0,0 +1,36 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { apiLogger } from './logger'; + +@Catch() +export class LoggingExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost): void { + const http = host.switchToHttp(); + const response = http.getResponse(); + const request = http.getRequest(); + + const status = + exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; + + const payload = + exception instanceof HttpException + ? exception.getResponse() + : { + message: 'Internal server error', + }; + + apiLogger.error('Unhandled exception', { + event: 'unhandled_exception', + method: request.method, + route: request.originalUrl, + statusCode: status, + correlationId: request.correlationId, + error: exception, + }); + + response.status(status).json({ + ...(typeof payload === 'string' ? { message: payload } : payload), + correlationId: request.correlationId, + }); + } +} diff --git a/apps/api/src/observability/request-context.middleware.ts b/apps/api/src/observability/request-context.middleware.ts new file mode 100644 index 0000000..5ccbc5b --- /dev/null +++ b/apps/api/src/observability/request-context.middleware.ts @@ -0,0 +1,39 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import type { NextFunction, Request, Response } from 'express'; +import { createCorrelationId, runWithRequestContext } from './request-context'; + +declare module 'express-serve-static-core' { + interface Request { + correlationId?: string; + } +} + +@Injectable() +export class RequestContextMiddleware implements NestMiddleware { + use(request: Request, response: Response, next: NextFunction): void { + const correlationId = createCorrelationId( + readHeaderValue(request.headers['x-correlation-id']) ?? + readHeaderValue(request.headers['x-request-id']), + ); + + request.correlationId = correlationId; + response.setHeader('x-correlation-id', correlationId); + + runWithRequestContext( + { + correlationId, + method: request.method, + route: request.originalUrl, + }, + next, + ); + } +} + +function readHeaderValue(header: string | string[] | undefined): string | undefined { + if (Array.isArray(header)) { + return header[0]; + } + + return header; +} diff --git a/apps/api/src/observability/request-context.ts b/apps/api/src/observability/request-context.ts new file mode 100644 index 0000000..5b8f3bc --- /dev/null +++ b/apps/api/src/observability/request-context.ts @@ -0,0 +1,42 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; + +export interface ApiRequestContext { + correlationId: string; + requestId?: string; + userId?: string; + route?: string; + method?: string; + [key: string]: unknown; +} + +const storage = new AsyncLocalStorage(); + +export function createCorrelationId(seed?: string): string { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} + +export function runWithRequestContext( + context: Partial, + callback: () => T, +): T { + const current = storage.getStore(); + + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId), + }, + callback, + ); +} + +export function getRequestContext(): ApiRequestContext | undefined { + return storage.getStore(); +} + +export function getCorrelationId(): string | undefined { + return storage.getStore()?.correlationId; +} diff --git a/apps/api/src/observability/request-logging.interceptor.ts b/apps/api/src/observability/request-logging.interceptor.ts new file mode 100644 index 0000000..71cee13 --- /dev/null +++ b/apps/api/src/observability/request-logging.interceptor.ts @@ -0,0 +1,45 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import type { Request } from 'express'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { apiLogger } from './logger'; + +@Injectable() +export class RequestLoggingInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const http = context.switchToHttp(); + const request = http.getRequest< + Request & { correlationId?: string; user?: { sub?: string } } + >(); + const response = http.getResponse<{ statusCode?: number }>(); + const start = Date.now(); + + return next.handle().pipe( + tap({ + next: () => { + apiLogger.info('HTTP request completed', { + event: 'http_request_completed', + method: request.method, + route: request.originalUrl, + statusCode: response.statusCode, + durationMs: Date.now() - start, + correlationId: request.correlationId, + userId: request.user?.sub, + }); + }, + error: (error: unknown) => { + apiLogger.error('HTTP request failed', { + event: 'http_request_failed', + method: request.method, + route: request.originalUrl, + statusCode: response.statusCode, + durationMs: Date.now() - start, + correlationId: request.correlationId, + userId: request.user?.sub, + error, + }); + }, + }), + ); + } +} diff --git a/apps/api/src/treasury/treasury.controller.ts b/apps/api/src/treasury/treasury.controller.ts index 65b2d62..6158d29 100644 --- a/apps/api/src/treasury/treasury.controller.ts +++ b/apps/api/src/treasury/treasury.controller.ts @@ -1,6 +1,11 @@ import { Controller, Get } from '@nestjs/common'; import { TreasuryService } from './treasury.service'; import { ProofOfReservesResponse } from './interfaces/proof-of-reserves.interface'; +import { apiLogger } from '../observability'; + +const controllerLogger = apiLogger.child({ + controller: 'TreasuryController', +}); @Controller('treasury') export class TreasuryController { @@ -12,6 +17,11 @@ export class TreasuryController { // const supportedAssets = await this.configService.getSupportedAssets(); const supportedAssets = (process.env.SUPPORTED_ASSETS ?? 'USDC,ARS').split(','); + controllerLogger.info('Generating proof of reserves snapshot', { + event: 'treasury_proof_of_reserves_requested', + supportedAssets: supportedAssets.map((asset) => asset.trim()), + }); + const reserves = await Promise.all( supportedAssets.map((asset) => this.treasuryService.getAssetReserve(asset.trim())), ); diff --git a/apps/api/src/treasury/treasury.service.ts b/apps/api/src/treasury/treasury.service.ts index 58f640c..5d2350a 100644 --- a/apps/api/src/treasury/treasury.service.ts +++ b/apps/api/src/treasury/treasury.service.ts @@ -1,5 +1,10 @@ import { Injectable } from '@nestjs/common'; import { AssetReserve } from './interfaces/proof-of-reserves.interface'; +import { apiLogger } from '../observability'; + +const serviceLogger = apiLogger.child({ + serviceContext: 'TreasuryService', +}); @Injectable() export class TreasuryService { @@ -13,7 +18,10 @@ export class TreasuryService { // const balance = acc.balances.find((b: any) => b.asset_code === assetCode); // return sum + (balance ? parseFloat(balance.balance) : 0); // }, 0).toString(); - + serviceLogger.debug('Fetching total supply placeholder response', { + event: 'treasury_total_supply_requested', + assetCode: _assetCode, + }); return '0'; } @@ -24,7 +32,11 @@ export class TreasuryService { // const account = await horizon.loadAccount(treasuryAddress); // const balance = account.balances.find((b: any) => b.asset_code === assetCode); // return balance?.balance ?? '0'; - + serviceLogger.debug('Fetching treasury balance placeholder response', { + event: 'treasury_balance_requested', + assetCode: _assetCode, + treasuryAddress: _treasuryAddress, + }); return '0'; } @@ -42,6 +54,13 @@ export class TreasuryService { // const treasuryAddress = await this.configService.getTreasuryAddress(); const treasuryAddress = process.env.TREASURY_WALLET_ADDRESS ?? 'TREASURY_ADDRESS_NOT_SET'; + if (treasuryAddress === 'TREASURY_ADDRESS_NOT_SET') { + serviceLogger.warn('Treasury wallet address missing, using placeholder', { + event: 'treasury_wallet_address_missing', + assetCode, + }); + } + const [totalSupply, treasuryBalance] = await Promise.all([ this.getTotalSupply(assetCode), this.getTreasuryBalance(assetCode, treasuryAddress), @@ -49,6 +68,14 @@ export class TreasuryService { const reserveRatio = this.calculateReserveRatio(treasuryBalance, totalSupply); + serviceLogger.info('Computed asset reserve snapshot', { + event: 'treasury_asset_reserve_computed', + assetCode, + totalSupply, + treasuryBalance, + reserveRatio, + }); + return { symbol: assetCode, total_supply: totalSupply, diff --git a/apps/frontend/src/app/dashboard/audit-logs/page.tsx b/apps/frontend/src/app/dashboard/audit-logs/page.tsx index 6b3c242..1df9cb1 100644 --- a/apps/frontend/src/app/dashboard/audit-logs/page.tsx +++ b/apps/frontend/src/app/dashboard/audit-logs/page.tsx @@ -1,125 +1,303 @@ 'use client'; -import { motion } from "motion/react"; -import { Filter, Download, Activity } from "lucide-react"; - -const auditLogs = [ - { timestamp: "2026-03-03 14:32:15", action: "API Key Created", actor: "admin@acmecorp.com", ip: "192.168.1.100", details: "Created sk_live_***", status: "success" }, - { timestamp: "2026-03-03 14:28:42", action: "Payment Initiated", actor: "api-service", ip: "10.0.1.50", details: "pay_9k2j3n4k5j6h", status: "success" }, - { timestamp: "2026-03-03 14:24:08", action: "Webhook Updated", actor: "admin@acmecorp.com", ip: "192.168.1.100", details: "wh_1a2b3c", status: "success" }, - { timestamp: "2026-03-03 14:15:22", action: "Login Attempt", actor: "admin@acmecorp.com", ip: "192.168.1.100", details: "2FA verified", status: "success" }, - { timestamp: "2026-03-03 13:58:45", action: "Treasury Redemption", actor: "api-service", ip: "10.0.1.50", details: "5,000.00 sUSDC", status: "success" }, - { timestamp: "2026-03-03 13:42:18", action: "Login Attempt", actor: "unknown@suspicious.com", ip: "185.220.101.52", details: "Failed authentication", status: "failed" }, - { timestamp: "2026-03-03 12:15:30", action: "Compliance Document Uploaded", actor: "admin@acmecorp.com", ip: "192.168.1.100", details: "Banking Information", status: "success" }, - { timestamp: "2026-03-03 11:32:45", action: "Escrow Created", actor: "api-service", ip: "10.0.1.50", details: "esc_1a2b3c", status: "success" }, +import { motion } from 'motion/react'; +import { Activity, BellRing, Cloud, Search, ShieldAlert, Waypoints } from 'lucide-react'; + +const summaryCards = [ + { + label: 'Events / 24h', + value: '15,247', + detail: 'CloudWatch log group /stellar-pay/production', + }, + { + label: 'Correlated Requests', + value: '98.3%', + detail: 'Matched by x-correlation-id across services', + }, + { label: 'Active Alerts', value: '2', detail: 'Error spike and high latency alarms provisioned' }, + { + label: 'Slow Requests', + value: '17', + detail: 'Requests above the 2s threshold in the last hour', + }, +]; + +const serviceRollup = [ + { service: 'stellar-pay-api', volume: '8,913', status: 'Healthy' }, + { service: '@stellar-pay/payments-engine', volume: '2,784', status: 'Healthy' }, + { service: '@stellar-pay/compliance-engine', volume: '1,966', status: 'Investigate' }, + { service: '@stellar-pay/subscriptions', volume: '1,584', status: 'Healthy' }, +]; + +const alerts = [ + { + name: 'stellar-pay-error-spike', + state: 'Armed', + threshold: '>= 5 errors / 5 min', + action: 'SNS notification', + }, + { + name: 'stellar-pay-high-latency', + state: 'Armed', + threshold: '>= 3 slow requests / 5 min', + action: 'SNS notification', + }, +]; + +const logs = [ + { + timestamp: '2026-03-24 18:21:43', + service: 'stellar-pay-api', + level: 'info', + correlationId: 'req_8b0d2f80d761', + route: 'GET /treasury/reserves', + message: 'HTTP request completed', + latency: '184ms', + }, + { + timestamp: '2026-03-24 18:21:43', + service: '@stellar-pay/payments-engine', + level: 'info', + correlationId: 'req_8b0d2f80d761', + route: 'reserve-calculation', + message: 'Computed asset reserve snapshot', + latency: '141ms', + }, + { + timestamp: '2026-03-24 18:20:02', + service: 'stellar-pay-api', + level: 'warn', + correlationId: 'req_61d5eb9c0f25', + route: 'GET /treasury/reserves', + message: 'Treasury wallet address missing, using placeholder', + latency: '-', + }, + { + timestamp: '2026-03-24 18:18:27', + service: '@stellar-pay/compliance-engine', + level: 'error', + correlationId: 'req_d0a77d1cb4aa', + route: 'transaction-screening', + message: 'HTTP request failed', + latency: '2.8s', + }, + { + timestamp: '2026-03-24 18:15:04', + service: '@stellar-pay/subscriptions', + level: 'info', + correlationId: 'req_7f90da2814d8', + route: 'subscription-renewal', + message: 'JSON payload forwarded to CloudWatch', + latency: '96ms', + }, +]; + +const queries = [ + { + label: 'Recent correlated requests', + snippet: + 'fields @timestamp, service, level, correlationId, route, statusCode, durationMs, message | sort @timestamp desc | limit 50', + }, + { + label: 'Trace a single request', + snippet: + 'fields @timestamp, service, level, correlationId, message, error.message | filter correlationId = "" | sort @timestamp asc', + }, + { + label: 'Slow requests', + snippet: + 'fields @timestamp, route, durationMs, correlationId, statusCode | filter event = "http_request_completed" and durationMs >= 2000 | sort @timestamp desc', + }, ]; export default function AuditLogsPage() { return (
-
- +
+ + Aggregated Log Viewer + +

+ Structured JSON logs, request correlation IDs, CloudWatch dashboards, and alert + thresholds in one place. +

+
+ + - Audit Logs -
-

Complete activity history with tamper-proof logging

+ + CloudWatch integration ready +
-
- {[ - { label: "Total Events", value: "15,247" }, - { label: "Today", value: "342" }, - { label: "Failed Actions", value: "1" }, - { label: "Security Alerts", value: "0" }, - ].map((stat, index) => ( +
+ {summaryCards.map((card, index) => ( -
{stat.value}
-
{stat.label}
+
{card.label}
+
{card.value}
+
{card.detail}
))}
+
+ +
+ + Service Rollup +
+ +
+ {serviceRollup.map((service) => ( +
+
+
{service.service}
+
+ {service.volume} events in the last 24h +
+
+ + {service.status} + +
+ ))} +
+
+ + +
+ + Alert Policies +
+ +
+ {alerts.map((alert) => ( +
+
+
{alert.name}
+ + {alert.state} + +
+
{alert.threshold}
+
{alert.action}
+
+ ))} +
+
+
+ -
- - Live monitoring active +
+ + Logs Insights Queries +
+ +
+ {queries.map((query) => ( +
+
{query.label}
+ {query.snippet} +
+ ))}
-
- - +
+
+ + Recent Aggregated Events +
+
+ + Correlation IDs retained in every request path +
+
+
- - - - - - - + + + + + + + + - {auditLogs.map((log, index) => ( + {logs.map((log, index) => ( - - - - - - + + + + + + ))} diff --git a/docs/observability/README.md b/docs/observability/README.md new file mode 100644 index 0000000..2fe69e2 --- /dev/null +++ b/docs/observability/README.md @@ -0,0 +1,65 @@ +# CloudWatch Observability + +This setup standardizes structured JSON logs for the API and workspace services, then aggregates them in Amazon CloudWatch. + +## What Ships In This Repo + +- JSON loggers in every package under `packages/*/src/logger.ts` +- API request correlation IDs via `x-correlation-id` +- CloudWatch dashboard definition in `cloudwatch-dashboard.json` +- CloudWatch metric filters in `cloudwatch-metric-filters.json` +- CloudWatch alarms in `cloudwatch-alarms.json` +- Deployment helper in `scripts/observability/deploy-cloudwatch.ps1` + +## Logging Contract + +Every emitted log line is JSON and includes these fields when available: + +- `timestamp` +- `level` +- `service` +- `message` +- `correlationId` +- `event` +- `route` +- `statusCode` +- `durationMs` +- `error` + +Forward stdout/stderr from each runtime into the same CloudWatch log group, for example `/stellar-pay/production`. This repo assumes a shared log group and service-level differentiation through the `service` field. + +## Deploy + +Run from the repo root with AWS credentials that can manage CloudWatch resources: + +```powershell +./scripts/observability/deploy-cloudwatch.ps1 -Region us-east-1 -LogGroup /stellar-pay/production -DashboardName stellar-pay-observability -AlarmTopicArn arn:aws:sns:us-east-1:123456789012:stellar-pay-alerts +``` + +The script creates the log group if needed, applies the retention policy, provisions metric filters, uploads the dashboard, and configures the alarms. + +## Log Viewer Queries + +Recent correlated requests: + +```text +fields @timestamp, service, level, correlationId, route, statusCode, durationMs, message +| sort @timestamp desc +| limit 50 +``` + +Errors for a specific request trace: + +```text +fields @timestamp, service, level, correlationId, message, error.message +| filter correlationId = "" +| sort @timestamp asc +``` + +Slow requests over 2 seconds: + +```text +fields @timestamp, route, durationMs, correlationId, statusCode +| filter event = "http_request_completed" and durationMs >= 2000 +| sort @timestamp desc +``` diff --git a/docs/observability/cloudwatch-alarms.json b/docs/observability/cloudwatch-alarms.json new file mode 100644 index 0000000..6efca6a --- /dev/null +++ b/docs/observability/cloudwatch-alarms.json @@ -0,0 +1,40 @@ +[ + { + "AlarmName": "stellar-pay-error-spike", + "AlarmDescription": "Triggers when error logs spike above the tolerated threshold.", + "Namespace": "StellarPay/Observability", + "MetricName": "ErrorCount", + "Dimensions": [ + { + "Name": "LogGroup", + "Value": "__LOG_GROUP__" + } + ], + "Statistic": "Sum", + "Period": 300, + "EvaluationPeriods": 1, + "DatapointsToAlarm": 1, + "Threshold": 5, + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "TreatMissingData": "notBreaching" + }, + { + "AlarmName": "stellar-pay-high-latency", + "AlarmDescription": "Triggers when slow requests persist across the aggregation window.", + "Namespace": "StellarPay/Observability", + "MetricName": "HighLatencyRequestCount", + "Dimensions": [ + { + "Name": "LogGroup", + "Value": "__LOG_GROUP__" + } + ], + "Statistic": "Sum", + "Period": 300, + "EvaluationPeriods": 1, + "DatapointsToAlarm": 1, + "Threshold": 3, + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "TreatMissingData": "notBreaching" + } +] diff --git a/docs/observability/cloudwatch-dashboard.json b/docs/observability/cloudwatch-dashboard.json new file mode 100644 index 0000000..04ed8b8 --- /dev/null +++ b/docs/observability/cloudwatch-dashboard.json @@ -0,0 +1,78 @@ +{ + "widgets": [ + { + "type": "log", + "width": 24, + "height": 8, + "x": 0, + "y": 0, + "properties": { + "title": "Recent Correlated Requests", + "region": "__REGION__", + "view": "table", + "query": "SOURCE '__LOG_GROUP__' | fields @timestamp, service, level, correlationId, route, statusCode, durationMs, message | sort @timestamp desc | limit 50" + } + }, + { + "type": "log", + "width": 12, + "height": 8, + "x": 0, + "y": 8, + "properties": { + "title": "Errors by Service", + "region": "__REGION__", + "view": "timeSeries", + "query": "SOURCE '__LOG_GROUP__' | filter level = 'error' | stats count() as errors by bin(5m), service" + } + }, + { + "type": "log", + "width": 12, + "height": 8, + "x": 12, + "y": 8, + "properties": { + "title": "P95 Latency", + "region": "__REGION__", + "view": "timeSeries", + "query": "SOURCE '__LOG_GROUP__' | filter event = 'http_request_completed' | stats pct(durationMs, 95) as p95LatencyMs by bin(5m)" + } + }, + { + "type": "metric", + "width": 12, + "height": 6, + "x": 0, + "y": 16, + "properties": { + "title": "Error and Warning Counts", + "region": "__REGION__", + "view": "timeSeries", + "stat": "Sum", + "period": 300, + "metrics": [ + ["StellarPay/Observability", "ErrorCount", "LogGroup", "__LOG_GROUP__"], + [".", "WarningCount", ".", "."] + ] + } + }, + { + "type": "metric", + "width": 12, + "height": 6, + "x": 12, + "y": 16, + "properties": { + "title": "High Latency Request Count", + "region": "__REGION__", + "view": "singleValue", + "stat": "Sum", + "period": 300, + "metrics": [ + ["StellarPay/Observability", "HighLatencyRequestCount", "LogGroup", "__LOG_GROUP__"] + ] + } + } + ] +} diff --git a/docs/observability/cloudwatch-metric-filters.json b/docs/observability/cloudwatch-metric-filters.json new file mode 100644 index 0000000..4606abc --- /dev/null +++ b/docs/observability/cloudwatch-metric-filters.json @@ -0,0 +1,47 @@ +[ + { + "filterName": "stellar-pay-error-count", + "filterPattern": "{ $.level = \"error\" }", + "metricTransformations": [ + { + "metricName": "ErrorCount", + "metricNamespace": "StellarPay/Observability", + "metricValue": "1", + "defaultValue": 0, + "dimensions": { + "LogGroup": "__LOG_GROUP__" + } + } + ] + }, + { + "filterName": "stellar-pay-warning-count", + "filterPattern": "{ $.level = \"warn\" }", + "metricTransformations": [ + { + "metricName": "WarningCount", + "metricNamespace": "StellarPay/Observability", + "metricValue": "1", + "defaultValue": 0, + "dimensions": { + "LogGroup": "__LOG_GROUP__" + } + } + ] + }, + { + "filterName": "stellar-pay-high-latency-count", + "filterPattern": "{ $.event = \"http_request_completed\" && $.durationMs >= 2000 }", + "metricTransformations": [ + { + "metricName": "HighLatencyRequestCount", + "metricNamespace": "StellarPay/Observability", + "metricValue": "1", + "defaultValue": 0, + "dimensions": { + "LogGroup": "__LOG_GROUP__" + } + } + ] + } +] diff --git a/packages/anchor-service/src/index.ts b/packages/anchor-service/src/index.ts index e69de29..92c0130 100644 --- a/packages/anchor-service/src/index.ts +++ b/packages/anchor-service/src/index.ts @@ -0,0 +1,2 @@ +export * from './logger'; +export * from './request-context'; diff --git a/packages/anchor-service/src/logger.ts b/packages/anchor-service/src/logger.ts new file mode 100644 index 0000000..221c9c1 --- /dev/null +++ b/packages/anchor-service/src/logger.ts @@ -0,0 +1,173 @@ +import { getCorrelationId, getRequestContext } from './request-context'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export interface LogTransport { + write(entry: StructuredLogEntry): void; +} + +export interface StructuredLogEntry { + timestamp: string; + level: LogLevel; + service: string; + message: string; + environment: string; + correlationId?: string; + error?: { + name: string; + message: string; + stack?: string; + }; + [key: string]: unknown; +} + +export type LogMetadata = Record; + +interface LoggerOptions { + serviceName: string; + transport?: LogTransport; + defaults?: LogMetadata; +} + +class ConsoleJsonTransport implements LogTransport { + write(entry: StructuredLogEntry): void { + const serialized = JSON.stringify(entry); + + if (entry.level === 'error') { + console.error(serialized); + return; + } + + if (entry.level === 'warn') { + console.warn(serialized); + return; + } + + console.log(serialized); + } +} + +export class StructuredLogger { + private readonly transport: LogTransport; + + constructor(private readonly options: LoggerOptions) { + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + + child(defaults: LogMetadata): StructuredLogger { + return new StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults, + }, + transport: this.transport, + }); + } + + debug(message: string, metadata: LogMetadata = {}): void { + this.log('debug', message, metadata); + } + + info(message: string, metadata: LogMetadata = {}): void { + this.log('info', message, metadata); + } + + warn(message: string, metadata: LogMetadata = {}): void { + this.log('warn', message, metadata); + } + + error(message: string, metadata: LogMetadata = {}): void { + this.log('error', message, metadata); + } + + private log(level: LogLevel, message: string, metadata: LogMetadata): void { + if (!shouldLog(level)) { + return; + } + + const context = getRequestContext(); + const entry: StructuredLogEntry = { + timestamp: new Date().toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? 'development', + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata), + }; + + Object.keys(entry).forEach((key) => { + if (entry[key] === undefined) { + delete entry[key]; + } + }); + + this.transport.write(entry); + } +} + +function shouldLog(level: LogLevel): boolean { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} + +function parseLogLevel(value: string | undefined): LogLevel { + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + return value; + } + + return 'info'; +} + +function serializeMetadata(metadata: LogMetadata): LogMetadata { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === undefined) { + return result; + } + + if (key === 'error') { + result.error = serializeError(value); + return result; + } + + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + + result[key] = value; + return result; + }, {}); +} + +function serializeError(error: unknown): StructuredLogEntry['error'] | unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return error; +} + +export function createLogger(options: Partial = {}): StructuredLogger { + return new StructuredLogger({ + serviceName: options.serviceName ?? '@stellar-pay/anchor-service', + transport: options.transport, + defaults: options.defaults, + }); +} + +export const logger = createLogger({ serviceName: '@stellar-pay/anchor-service' }); diff --git a/packages/anchor-service/src/request-context.ts b/packages/anchor-service/src/request-context.ts new file mode 100644 index 0000000..9d7cd4a --- /dev/null +++ b/packages/anchor-service/src/request-context.ts @@ -0,0 +1,42 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; + +export interface RequestContext { + correlationId: string; + requestId?: string; + traceId?: string; + userId?: string; + [key: string]: unknown; +} + +const storage = new AsyncLocalStorage(); + +export function createCorrelationId(seed?: string): string { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} + +export function runWithRequestContext(context: Partial, callback: () => T): T { + const current = storage.getStore(); + + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId), + }, + callback, + ); +} + +export function runWithCorrelationId(correlationId: string, callback: () => T): T { + return runWithRequestContext({ correlationId }, callback); +} + +export function getRequestContext(): RequestContext | undefined { + return storage.getStore(); +} + +export function getCorrelationId(): string | undefined { + return storage.getStore()?.correlationId; +} diff --git a/packages/compliance-engine/src/index.ts b/packages/compliance-engine/src/index.ts index e69de29..92c0130 100644 --- a/packages/compliance-engine/src/index.ts +++ b/packages/compliance-engine/src/index.ts @@ -0,0 +1,2 @@ +export * from './logger'; +export * from './request-context'; diff --git a/packages/compliance-engine/src/logger.ts b/packages/compliance-engine/src/logger.ts new file mode 100644 index 0000000..f3b1886 --- /dev/null +++ b/packages/compliance-engine/src/logger.ts @@ -0,0 +1,173 @@ +import { getCorrelationId, getRequestContext } from './request-context'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export interface LogTransport { + write(entry: StructuredLogEntry): void; +} + +export interface StructuredLogEntry { + timestamp: string; + level: LogLevel; + service: string; + message: string; + environment: string; + correlationId?: string; + error?: { + name: string; + message: string; + stack?: string; + }; + [key: string]: unknown; +} + +export type LogMetadata = Record; + +interface LoggerOptions { + serviceName: string; + transport?: LogTransport; + defaults?: LogMetadata; +} + +class ConsoleJsonTransport implements LogTransport { + write(entry: StructuredLogEntry): void { + const serialized = JSON.stringify(entry); + + if (entry.level === 'error') { + console.error(serialized); + return; + } + + if (entry.level === 'warn') { + console.warn(serialized); + return; + } + + console.log(serialized); + } +} + +export class StructuredLogger { + private readonly transport: LogTransport; + + constructor(private readonly options: LoggerOptions) { + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + + child(defaults: LogMetadata): StructuredLogger { + return new StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults, + }, + transport: this.transport, + }); + } + + debug(message: string, metadata: LogMetadata = {}): void { + this.log('debug', message, metadata); + } + + info(message: string, metadata: LogMetadata = {}): void { + this.log('info', message, metadata); + } + + warn(message: string, metadata: LogMetadata = {}): void { + this.log('warn', message, metadata); + } + + error(message: string, metadata: LogMetadata = {}): void { + this.log('error', message, metadata); + } + + private log(level: LogLevel, message: string, metadata: LogMetadata): void { + if (!shouldLog(level)) { + return; + } + + const context = getRequestContext(); + const entry: StructuredLogEntry = { + timestamp: new Date().toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? 'development', + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata), + }; + + Object.keys(entry).forEach((key) => { + if (entry[key] === undefined) { + delete entry[key]; + } + }); + + this.transport.write(entry); + } +} + +function shouldLog(level: LogLevel): boolean { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} + +function parseLogLevel(value: string | undefined): LogLevel { + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + return value; + } + + return 'info'; +} + +function serializeMetadata(metadata: LogMetadata): LogMetadata { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === undefined) { + return result; + } + + if (key === 'error') { + result.error = serializeError(value); + return result; + } + + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + + result[key] = value; + return result; + }, {}); +} + +function serializeError(error: unknown): StructuredLogEntry['error'] | unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return error; +} + +export function createLogger(options: Partial = {}): StructuredLogger { + return new StructuredLogger({ + serviceName: options.serviceName ?? '@stellar-pay/compliance-engine', + transport: options.transport, + defaults: options.defaults, + }); +} + +export const logger = createLogger({ serviceName: '@stellar-pay/compliance-engine' }); diff --git a/packages/compliance-engine/src/request-context.ts b/packages/compliance-engine/src/request-context.ts new file mode 100644 index 0000000..9d7cd4a --- /dev/null +++ b/packages/compliance-engine/src/request-context.ts @@ -0,0 +1,42 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; + +export interface RequestContext { + correlationId: string; + requestId?: string; + traceId?: string; + userId?: string; + [key: string]: unknown; +} + +const storage = new AsyncLocalStorage(); + +export function createCorrelationId(seed?: string): string { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} + +export function runWithRequestContext(context: Partial, callback: () => T): T { + const current = storage.getStore(); + + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId), + }, + callback, + ); +} + +export function runWithCorrelationId(correlationId: string, callback: () => T): T { + return runWithRequestContext({ correlationId }, callback); +} + +export function getRequestContext(): RequestContext | undefined { + return storage.getStore(); +} + +export function getCorrelationId(): string | undefined { + return storage.getStore()?.correlationId; +} diff --git a/packages/escrow/src/index.ts b/packages/escrow/src/index.ts index e69de29..92c0130 100644 --- a/packages/escrow/src/index.ts +++ b/packages/escrow/src/index.ts @@ -0,0 +1,2 @@ +export * from './logger'; +export * from './request-context'; diff --git a/packages/escrow/src/logger.ts b/packages/escrow/src/logger.ts new file mode 100644 index 0000000..2fff9ea --- /dev/null +++ b/packages/escrow/src/logger.ts @@ -0,0 +1,173 @@ +import { getCorrelationId, getRequestContext } from './request-context'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export interface LogTransport { + write(entry: StructuredLogEntry): void; +} + +export interface StructuredLogEntry { + timestamp: string; + level: LogLevel; + service: string; + message: string; + environment: string; + correlationId?: string; + error?: { + name: string; + message: string; + stack?: string; + }; + [key: string]: unknown; +} + +export type LogMetadata = Record; + +interface LoggerOptions { + serviceName: string; + transport?: LogTransport; + defaults?: LogMetadata; +} + +class ConsoleJsonTransport implements LogTransport { + write(entry: StructuredLogEntry): void { + const serialized = JSON.stringify(entry); + + if (entry.level === 'error') { + console.error(serialized); + return; + } + + if (entry.level === 'warn') { + console.warn(serialized); + return; + } + + console.log(serialized); + } +} + +export class StructuredLogger { + private readonly transport: LogTransport; + + constructor(private readonly options: LoggerOptions) { + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + + child(defaults: LogMetadata): StructuredLogger { + return new StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults, + }, + transport: this.transport, + }); + } + + debug(message: string, metadata: LogMetadata = {}): void { + this.log('debug', message, metadata); + } + + info(message: string, metadata: LogMetadata = {}): void { + this.log('info', message, metadata); + } + + warn(message: string, metadata: LogMetadata = {}): void { + this.log('warn', message, metadata); + } + + error(message: string, metadata: LogMetadata = {}): void { + this.log('error', message, metadata); + } + + private log(level: LogLevel, message: string, metadata: LogMetadata): void { + if (!shouldLog(level)) { + return; + } + + const context = getRequestContext(); + const entry: StructuredLogEntry = { + timestamp: new Date().toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? 'development', + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata), + }; + + Object.keys(entry).forEach((key) => { + if (entry[key] === undefined) { + delete entry[key]; + } + }); + + this.transport.write(entry); + } +} + +function shouldLog(level: LogLevel): boolean { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} + +function parseLogLevel(value: string | undefined): LogLevel { + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + return value; + } + + return 'info'; +} + +function serializeMetadata(metadata: LogMetadata): LogMetadata { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === undefined) { + return result; + } + + if (key === 'error') { + result.error = serializeError(value); + return result; + } + + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + + result[key] = value; + return result; + }, {}); +} + +function serializeError(error: unknown): StructuredLogEntry['error'] | unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return error; +} + +export function createLogger(options: Partial = {}): StructuredLogger { + return new StructuredLogger({ + serviceName: options.serviceName ?? '@stellar-pay/escrow', + transport: options.transport, + defaults: options.defaults, + }); +} + +export const logger = createLogger({ serviceName: '@stellar-pay/escrow' }); diff --git a/packages/escrow/src/request-context.ts b/packages/escrow/src/request-context.ts new file mode 100644 index 0000000..9d7cd4a --- /dev/null +++ b/packages/escrow/src/request-context.ts @@ -0,0 +1,42 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; + +export interface RequestContext { + correlationId: string; + requestId?: string; + traceId?: string; + userId?: string; + [key: string]: unknown; +} + +const storage = new AsyncLocalStorage(); + +export function createCorrelationId(seed?: string): string { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} + +export function runWithRequestContext(context: Partial, callback: () => T): T { + const current = storage.getStore(); + + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId), + }, + callback, + ); +} + +export function runWithCorrelationId(correlationId: string, callback: () => T): T { + return runWithRequestContext({ correlationId }, callback); +} + +export function getRequestContext(): RequestContext | undefined { + return storage.getStore(); +} + +export function getCorrelationId(): string | undefined { + return storage.getStore()?.correlationId; +} diff --git a/packages/payments-engine/src/index.ts b/packages/payments-engine/src/index.ts index e69de29..92c0130 100644 --- a/packages/payments-engine/src/index.ts +++ b/packages/payments-engine/src/index.ts @@ -0,0 +1,2 @@ +export * from './logger'; +export * from './request-context'; diff --git a/packages/payments-engine/src/logger.ts b/packages/payments-engine/src/logger.ts new file mode 100644 index 0000000..6e42744 --- /dev/null +++ b/packages/payments-engine/src/logger.ts @@ -0,0 +1,173 @@ +import { getCorrelationId, getRequestContext } from './request-context'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export interface LogTransport { + write(entry: StructuredLogEntry): void; +} + +export interface StructuredLogEntry { + timestamp: string; + level: LogLevel; + service: string; + message: string; + environment: string; + correlationId?: string; + error?: { + name: string; + message: string; + stack?: string; + }; + [key: string]: unknown; +} + +export type LogMetadata = Record; + +interface LoggerOptions { + serviceName: string; + transport?: LogTransport; + defaults?: LogMetadata; +} + +class ConsoleJsonTransport implements LogTransport { + write(entry: StructuredLogEntry): void { + const serialized = JSON.stringify(entry); + + if (entry.level === 'error') { + console.error(serialized); + return; + } + + if (entry.level === 'warn') { + console.warn(serialized); + return; + } + + console.log(serialized); + } +} + +export class StructuredLogger { + private readonly transport: LogTransport; + + constructor(private readonly options: LoggerOptions) { + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + + child(defaults: LogMetadata): StructuredLogger { + return new StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults, + }, + transport: this.transport, + }); + } + + debug(message: string, metadata: LogMetadata = {}): void { + this.log('debug', message, metadata); + } + + info(message: string, metadata: LogMetadata = {}): void { + this.log('info', message, metadata); + } + + warn(message: string, metadata: LogMetadata = {}): void { + this.log('warn', message, metadata); + } + + error(message: string, metadata: LogMetadata = {}): void { + this.log('error', message, metadata); + } + + private log(level: LogLevel, message: string, metadata: LogMetadata): void { + if (!shouldLog(level)) { + return; + } + + const context = getRequestContext(); + const entry: StructuredLogEntry = { + timestamp: new Date().toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? 'development', + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata), + }; + + Object.keys(entry).forEach((key) => { + if (entry[key] === undefined) { + delete entry[key]; + } + }); + + this.transport.write(entry); + } +} + +function shouldLog(level: LogLevel): boolean { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} + +function parseLogLevel(value: string | undefined): LogLevel { + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + return value; + } + + return 'info'; +} + +function serializeMetadata(metadata: LogMetadata): LogMetadata { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === undefined) { + return result; + } + + if (key === 'error') { + result.error = serializeError(value); + return result; + } + + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + + result[key] = value; + return result; + }, {}); +} + +function serializeError(error: unknown): StructuredLogEntry['error'] | unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return error; +} + +export function createLogger(options: Partial = {}): StructuredLogger { + return new StructuredLogger({ + serviceName: options.serviceName ?? '@stellar-pay/payments-engine', + transport: options.transport, + defaults: options.defaults, + }); +} + +export const logger = createLogger({ serviceName: '@stellar-pay/payments-engine' }); diff --git a/packages/payments-engine/src/request-context.ts b/packages/payments-engine/src/request-context.ts new file mode 100644 index 0000000..9d7cd4a --- /dev/null +++ b/packages/payments-engine/src/request-context.ts @@ -0,0 +1,42 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; + +export interface RequestContext { + correlationId: string; + requestId?: string; + traceId?: string; + userId?: string; + [key: string]: unknown; +} + +const storage = new AsyncLocalStorage(); + +export function createCorrelationId(seed?: string): string { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} + +export function runWithRequestContext(context: Partial, callback: () => T): T { + const current = storage.getStore(); + + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId), + }, + callback, + ); +} + +export function runWithCorrelationId(correlationId: string, callback: () => T): T { + return runWithRequestContext({ correlationId }, callback); +} + +export function getRequestContext(): RequestContext | undefined { + return storage.getStore(); +} + +export function getCorrelationId(): string | undefined { + return storage.getStore()?.correlationId; +} diff --git a/packages/sdk-js/src/index.ts b/packages/sdk-js/src/index.ts index e69de29..92c0130 100644 --- a/packages/sdk-js/src/index.ts +++ b/packages/sdk-js/src/index.ts @@ -0,0 +1,2 @@ +export * from './logger'; +export * from './request-context'; diff --git a/packages/sdk-js/src/logger.ts b/packages/sdk-js/src/logger.ts new file mode 100644 index 0000000..06900a3 --- /dev/null +++ b/packages/sdk-js/src/logger.ts @@ -0,0 +1,173 @@ +import { getCorrelationId, getRequestContext } from './request-context'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export interface LogTransport { + write(entry: StructuredLogEntry): void; +} + +export interface StructuredLogEntry { + timestamp: string; + level: LogLevel; + service: string; + message: string; + environment: string; + correlationId?: string; + error?: { + name: string; + message: string; + stack?: string; + }; + [key: string]: unknown; +} + +export type LogMetadata = Record; + +interface LoggerOptions { + serviceName: string; + transport?: LogTransport; + defaults?: LogMetadata; +} + +class ConsoleJsonTransport implements LogTransport { + write(entry: StructuredLogEntry): void { + const serialized = JSON.stringify(entry); + + if (entry.level === 'error') { + console.error(serialized); + return; + } + + if (entry.level === 'warn') { + console.warn(serialized); + return; + } + + console.log(serialized); + } +} + +export class StructuredLogger { + private readonly transport: LogTransport; + + constructor(private readonly options: LoggerOptions) { + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + + child(defaults: LogMetadata): StructuredLogger { + return new StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults, + }, + transport: this.transport, + }); + } + + debug(message: string, metadata: LogMetadata = {}): void { + this.log('debug', message, metadata); + } + + info(message: string, metadata: LogMetadata = {}): void { + this.log('info', message, metadata); + } + + warn(message: string, metadata: LogMetadata = {}): void { + this.log('warn', message, metadata); + } + + error(message: string, metadata: LogMetadata = {}): void { + this.log('error', message, metadata); + } + + private log(level: LogLevel, message: string, metadata: LogMetadata): void { + if (!shouldLog(level)) { + return; + } + + const context = getRequestContext(); + const entry: StructuredLogEntry = { + timestamp: new Date().toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? 'development', + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata), + }; + + Object.keys(entry).forEach((key) => { + if (entry[key] === undefined) { + delete entry[key]; + } + }); + + this.transport.write(entry); + } +} + +function shouldLog(level: LogLevel): boolean { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} + +function parseLogLevel(value: string | undefined): LogLevel { + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + return value; + } + + return 'info'; +} + +function serializeMetadata(metadata: LogMetadata): LogMetadata { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === undefined) { + return result; + } + + if (key === 'error') { + result.error = serializeError(value); + return result; + } + + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + + result[key] = value; + return result; + }, {}); +} + +function serializeError(error: unknown): StructuredLogEntry['error'] | unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return error; +} + +export function createLogger(options: Partial = {}): StructuredLogger { + return new StructuredLogger({ + serviceName: options.serviceName ?? '@stellar-pay/sdk-js', + transport: options.transport, + defaults: options.defaults, + }); +} + +export const logger = createLogger({ serviceName: '@stellar-pay/sdk-js' }); diff --git a/packages/sdk-js/src/request-context.ts b/packages/sdk-js/src/request-context.ts new file mode 100644 index 0000000..9d7cd4a --- /dev/null +++ b/packages/sdk-js/src/request-context.ts @@ -0,0 +1,42 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; + +export interface RequestContext { + correlationId: string; + requestId?: string; + traceId?: string; + userId?: string; + [key: string]: unknown; +} + +const storage = new AsyncLocalStorage(); + +export function createCorrelationId(seed?: string): string { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} + +export function runWithRequestContext(context: Partial, callback: () => T): T { + const current = storage.getStore(); + + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId), + }, + callback, + ); +} + +export function runWithCorrelationId(correlationId: string, callback: () => T): T { + return runWithRequestContext({ correlationId }, callback); +} + +export function getRequestContext(): RequestContext | undefined { + return storage.getStore(); +} + +export function getCorrelationId(): string | undefined { + return storage.getStore()?.correlationId; +} diff --git a/packages/subscriptions/src/index.ts b/packages/subscriptions/src/index.ts index e69de29..92c0130 100644 --- a/packages/subscriptions/src/index.ts +++ b/packages/subscriptions/src/index.ts @@ -0,0 +1,2 @@ +export * from './logger'; +export * from './request-context'; diff --git a/packages/subscriptions/src/logger.ts b/packages/subscriptions/src/logger.ts new file mode 100644 index 0000000..7aa61d2 --- /dev/null +++ b/packages/subscriptions/src/logger.ts @@ -0,0 +1,173 @@ +import { getCorrelationId, getRequestContext } from './request-context'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export interface LogTransport { + write(entry: StructuredLogEntry): void; +} + +export interface StructuredLogEntry { + timestamp: string; + level: LogLevel; + service: string; + message: string; + environment: string; + correlationId?: string; + error?: { + name: string; + message: string; + stack?: string; + }; + [key: string]: unknown; +} + +export type LogMetadata = Record; + +interface LoggerOptions { + serviceName: string; + transport?: LogTransport; + defaults?: LogMetadata; +} + +class ConsoleJsonTransport implements LogTransport { + write(entry: StructuredLogEntry): void { + const serialized = JSON.stringify(entry); + + if (entry.level === 'error') { + console.error(serialized); + return; + } + + if (entry.level === 'warn') { + console.warn(serialized); + return; + } + + console.log(serialized); + } +} + +export class StructuredLogger { + private readonly transport: LogTransport; + + constructor(private readonly options: LoggerOptions) { + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + + child(defaults: LogMetadata): StructuredLogger { + return new StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults, + }, + transport: this.transport, + }); + } + + debug(message: string, metadata: LogMetadata = {}): void { + this.log('debug', message, metadata); + } + + info(message: string, metadata: LogMetadata = {}): void { + this.log('info', message, metadata); + } + + warn(message: string, metadata: LogMetadata = {}): void { + this.log('warn', message, metadata); + } + + error(message: string, metadata: LogMetadata = {}): void { + this.log('error', message, metadata); + } + + private log(level: LogLevel, message: string, metadata: LogMetadata): void { + if (!shouldLog(level)) { + return; + } + + const context = getRequestContext(); + const entry: StructuredLogEntry = { + timestamp: new Date().toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? 'development', + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata), + }; + + Object.keys(entry).forEach((key) => { + if (entry[key] === undefined) { + delete entry[key]; + } + }); + + this.transport.write(entry); + } +} + +function shouldLog(level: LogLevel): boolean { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} + +function parseLogLevel(value: string | undefined): LogLevel { + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + return value; + } + + return 'info'; +} + +function serializeMetadata(metadata: LogMetadata): LogMetadata { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === undefined) { + return result; + } + + if (key === 'error') { + result.error = serializeError(value); + return result; + } + + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + + result[key] = value; + return result; + }, {}); +} + +function serializeError(error: unknown): StructuredLogEntry['error'] | unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return error; +} + +export function createLogger(options: Partial = {}): StructuredLogger { + return new StructuredLogger({ + serviceName: options.serviceName ?? '@stellar-pay/subscriptions', + transport: options.transport, + defaults: options.defaults, + }); +} + +export const logger = createLogger({ serviceName: '@stellar-pay/subscriptions' }); diff --git a/packages/subscriptions/src/request-context.ts b/packages/subscriptions/src/request-context.ts new file mode 100644 index 0000000..9d7cd4a --- /dev/null +++ b/packages/subscriptions/src/request-context.ts @@ -0,0 +1,42 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; + +export interface RequestContext { + correlationId: string; + requestId?: string; + traceId?: string; + userId?: string; + [key: string]: unknown; +} + +const storage = new AsyncLocalStorage(); + +export function createCorrelationId(seed?: string): string { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} + +export function runWithRequestContext(context: Partial, callback: () => T): T { + const current = storage.getStore(); + + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId), + }, + callback, + ); +} + +export function runWithCorrelationId(correlationId: string, callback: () => T): T { + return runWithRequestContext({ correlationId }, callback); +} + +export function getRequestContext(): RequestContext | undefined { + return storage.getStore(); +} + +export function getCorrelationId(): string | undefined { + return storage.getStore()?.correlationId; +} diff --git a/scripts/observability/deploy-cloudwatch.ps1 b/scripts/observability/deploy-cloudwatch.ps1 new file mode 100644 index 0000000..0b5e5c3 --- /dev/null +++ b/scripts/observability/deploy-cloudwatch.ps1 @@ -0,0 +1,77 @@ +param( + [string]$Region = 'us-east-1', + [string]$LogGroup = '/stellar-pay/production', + [string]$DashboardName = 'stellar-pay-observability', + [string]$AlarmTopicArn = '', + [int]$RetentionDays = 30 +) + +$ErrorActionPreference = 'Stop' + +function Replace-Placeholders { + param( + [Parameter(Mandatory = $true)] + [string]$Content + ) + + return $Content.Replace('__REGION__', $Region).Replace('__LOG_GROUP__', $LogGroup).Replace('__DASHBOARD_NAME__', $DashboardName) +} + +Write-Host "Ensuring CloudWatch log group $LogGroup exists in $Region" +$existingGroup = aws logs describe-log-groups --region $Region --log-group-name-prefix $LogGroup | ConvertFrom-Json +if (-not ($existingGroup.logGroups | Where-Object { $_.logGroupName -eq $LogGroup })) { + aws logs create-log-group --region $Region --log-group-name $LogGroup | Out-Null +} + +aws logs put-retention-policy --region $Region --log-group-name $LogGroup --retention-in-days $RetentionDays | Out-Null + +$metricFiltersPath = Join-Path $PSScriptRoot '..\..\docs\observability\cloudwatch-metric-filters.json' +$metricFilters = Get-Content $metricFiltersPath -Raw | Replace-Placeholders | ConvertFrom-Json +foreach ($filter in $metricFilters) { + $transformations = $filter.metricTransformations | ConvertTo-Json -Compress -Depth 10 + aws logs put-metric-filter ` + --region $Region ` + --log-group-name $LogGroup ` + --filter-name $filter.filterName ` + --filter-pattern $filter.filterPattern ` + --metric-transformations $transformations | Out-Null +} + +$dashboardPath = Join-Path $PSScriptRoot '..\..\docs\observability\cloudwatch-dashboard.json' +$dashboardBody = Get-Content $dashboardPath -Raw | Replace-Placeholders +aws cloudwatch put-dashboard --region $Region --dashboard-name $DashboardName --dashboard-body $dashboardBody | Out-Null + +$alarmsPath = Join-Path $PSScriptRoot '..\..\docs\observability\cloudwatch-alarms.json' +$alarms = Get-Content $alarmsPath -Raw | Replace-Placeholders | ConvertFrom-Json +foreach ($alarm in $alarms) { + $dimensionsArgs = @() + foreach ($dimension in $alarm.Dimensions) { + $dimensionsArgs += "Name=$($dimension.Name),Value=$($dimension.Value)" + } + + $command = @( + 'cloudwatch', + 'put-metric-alarm', + '--region', $Region, + '--alarm-name', $alarm.AlarmName, + '--alarm-description', $alarm.AlarmDescription, + '--namespace', $alarm.Namespace, + '--metric-name', $alarm.MetricName, + '--statistic', $alarm.Statistic, + '--period', $alarm.Period, + '--evaluation-periods', $alarm.EvaluationPeriods, + '--datapoints-to-alarm', $alarm.DatapointsToAlarm, + '--threshold', $alarm.Threshold, + '--comparison-operator', $alarm.ComparisonOperator, + '--treat-missing-data', $alarm.TreatMissingData, + '--dimensions' + ) + $dimensionsArgs + + if ($AlarmTopicArn) { + $command += @('--alarm-actions', $AlarmTopicArn) + } + + aws @command | Out-Null +} + +Write-Host 'CloudWatch observability assets applied successfully.' From 557dd1384ffe0adf249e8c0e0194cf4ce4e683be Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Tue, 24 Mar 2026 22:49:07 +0100 Subject: [PATCH 2/9] Add Kubernetes deployment manifests --- k8s/README.md | 42 +++++++++++++++++ k8s/admin-dashboard.yaml | 94 ++++++++++++++++++++++++++++++++++++++ k8s/anchor-service.yaml | 92 +++++++++++++++++++++++++++++++++++++ k8s/api.yaml | 92 +++++++++++++++++++++++++++++++++++++ k8s/compliance-engine.yaml | 92 +++++++++++++++++++++++++++++++++++++ k8s/configmap.yaml | 22 +++++++++ k8s/escrow.yaml | 92 +++++++++++++++++++++++++++++++++++++ k8s/frontend.yaml | 94 ++++++++++++++++++++++++++++++++++++++ k8s/ingress.yaml | 50 ++++++++++++++++++++ k8s/kustomization.yaml | 18 ++++++++ k8s/namespace.yaml | 4 ++ k8s/payments-engine.yaml | 92 +++++++++++++++++++++++++++++++++++++ k8s/secrets.yaml | 12 +++++ k8s/subscriptions.yaml | 92 +++++++++++++++++++++++++++++++++++++ 14 files changed, 888 insertions(+) create mode 100644 k8s/README.md create mode 100644 k8s/admin-dashboard.yaml create mode 100644 k8s/anchor-service.yaml create mode 100644 k8s/api.yaml create mode 100644 k8s/compliance-engine.yaml create mode 100644 k8s/configmap.yaml create mode 100644 k8s/escrow.yaml create mode 100644 k8s/frontend.yaml create mode 100644 k8s/ingress.yaml create mode 100644 k8s/kustomization.yaml create mode 100644 k8s/namespace.yaml create mode 100644 k8s/payments-engine.yaml create mode 100644 k8s/secrets.yaml create mode 100644 k8s/subscriptions.yaml diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000..9b7c3e3 --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,42 @@ +# Kubernetes Manifests + +This directory contains a base Kubernetes deployment layout for the Stellar Pay runtime services. + +## Included Resources + +- Namespace +- Shared ConfigMap +- Shared Secret +- Deployment, Service, and HorizontalPodAutoscaler for `api` +- Deployment, Service, and HorizontalPodAutoscaler for `frontend` +- Deployment, Service, and HorizontalPodAutoscaler for `admin-dashboard` +- Deployment, Service, and HorizontalPodAutoscaler for `payments-engine` +- Deployment, Service, and HorizontalPodAutoscaler for `anchor-service` +- Deployment, Service, and HorizontalPodAutoscaler for `compliance-engine` +- Deployment, Service, and HorizontalPodAutoscaler for `subscriptions` +- Deployment, Service, and HorizontalPodAutoscaler for `escrow` +- Host-based Ingress for the public surfaces + +`sdk-js` is intentionally excluded because it is a client library, not a deployable Kubernetes service. + +## Apply + +Update the placeholder values in `secrets.yaml`, publish the referenced container images, then apply the base: + +```powershell +kubectl apply -k k8s/ +``` + +## Probe Strategy + +- `api` uses `GET /health` for liveness and readiness. +- `frontend` and `admin-dashboard` use `GET /`. +- The internal engines use `GET /health` and assume each service image exposes an HTTP health endpoint. + +## Validation + +Client-side validation: + +```powershell +kubectl apply --dry-run=client -k k8s/ +``` diff --git a/k8s/admin-dashboard.yaml b/k8s/admin-dashboard.yaml new file mode 100644 index 0000000..478c81f --- /dev/null +++ b/k8s/admin-dashboard.yaml @@ -0,0 +1,94 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: admin-dashboard + namespace: stellar-pay +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: admin-dashboard + template: + metadata: + labels: + app.kubernetes.io/name: admin-dashboard + spec: + containers: + - name: admin-dashboard + image: ghcr.io/missblue00/stellar-pay-admin-dashboard:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 3000 + envFrom: + - configMapRef: + name: stellar-pay-config + - secretRef: + name: stellar-pay-secrets + env: + - name: PORT + value: '3000' + - name: HOSTNAME + value: '0.0.0.0' + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +--- +apiVersion: v1 +kind: Service +metadata: + name: admin-dashboard + namespace: stellar-pay +spec: + selector: + app.kubernetes.io/name: admin-dashboard + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: admin-dashboard + namespace: stellar-pay +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: admin-dashboard + minReplicas: 2 + maxReplicas: 4 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 75 diff --git a/k8s/anchor-service.yaml b/k8s/anchor-service.yaml new file mode 100644 index 0000000..c932b80 --- /dev/null +++ b/k8s/anchor-service.yaml @@ -0,0 +1,92 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: anchor-service + namespace: stellar-pay +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: anchor-service + template: + metadata: + labels: + app.kubernetes.io/name: anchor-service + spec: + containers: + - name: anchor-service + image: ghcr.io/missblue00/stellar-pay-anchor-service:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 3002 + envFrom: + - configMapRef: + name: stellar-pay-config + - secretRef: + name: stellar-pay-secrets + env: + - name: PORT + value: '3002' + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +--- +apiVersion: v1 +kind: Service +metadata: + name: anchor-service + namespace: stellar-pay +spec: + selector: + app.kubernetes.io/name: anchor-service + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: anchor-service + namespace: stellar-pay +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: anchor-service + minReplicas: 2 + maxReplicas: 6 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 75 diff --git a/k8s/api.yaml b/k8s/api.yaml new file mode 100644 index 0000000..fbbbd0a --- /dev/null +++ b/k8s/api.yaml @@ -0,0 +1,92 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api + namespace: stellar-pay +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: api + template: + metadata: + labels: + app.kubernetes.io/name: api + spec: + containers: + - name: api + image: ghcr.io/missblue00/stellar-pay-api:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 3000 + envFrom: + - configMapRef: + name: stellar-pay-config + - secretRef: + name: stellar-pay-secrets + env: + - name: PORT + value: '3000' + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +--- +apiVersion: v1 +kind: Service +metadata: + name: api + namespace: stellar-pay +spec: + selector: + app.kubernetes.io/name: api + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: api + namespace: stellar-pay +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: api + minReplicas: 2 + maxReplicas: 8 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 75 diff --git a/k8s/compliance-engine.yaml b/k8s/compliance-engine.yaml new file mode 100644 index 0000000..cb71c37 --- /dev/null +++ b/k8s/compliance-engine.yaml @@ -0,0 +1,92 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: compliance-engine + namespace: stellar-pay +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: compliance-engine + template: + metadata: + labels: + app.kubernetes.io/name: compliance-engine + spec: + containers: + - name: compliance-engine + image: ghcr.io/missblue00/stellar-pay-compliance-engine:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 3003 + envFrom: + - configMapRef: + name: stellar-pay-config + - secretRef: + name: stellar-pay-secrets + env: + - name: PORT + value: '3003' + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +--- +apiVersion: v1 +kind: Service +metadata: + name: compliance-engine + namespace: stellar-pay +spec: + selector: + app.kubernetes.io/name: compliance-engine + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: compliance-engine + namespace: stellar-pay +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: compliance-engine + minReplicas: 2 + maxReplicas: 6 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 75 diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml new file mode 100644 index 0000000..c3bddfb --- /dev/null +++ b/k8s/configmap.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: stellar-pay-config + namespace: stellar-pay +data: + NODE_ENV: production + STELLAR_NETWORK: TESTNET + STELLAR_HORIZON_URL: https://horizon-testnet.stellar.org + SUPPORTED_ASSETS: USDC,ARS + LOG_LEVEL: info + CLOUDWATCH_LOG_GROUP: /stellar-pay/production + CLOUDWATCH_DASHBOARD_NAME: stellar-pay-observability + NEXT_PUBLIC_SITE_URL: https://app.stellar-pay.local + NEXT_PUBLIC_ADMIN_URL: https://admin.stellar-pay.local + NEXT_PUBLIC_API_BASE_URL: https://api.stellar-pay.local + API_BASE_URL: http://api:3000 + PAYMENTS_ENGINE_URL: http://payments-engine:3001 + ANCHOR_SERVICE_URL: http://anchor-service:3002 + COMPLIANCE_ENGINE_URL: http://compliance-engine:3003 + SUBSCRIPTIONS_URL: http://subscriptions:3004 + ESCROW_URL: http://escrow:3005 diff --git a/k8s/escrow.yaml b/k8s/escrow.yaml new file mode 100644 index 0000000..ceaa45b --- /dev/null +++ b/k8s/escrow.yaml @@ -0,0 +1,92 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: escrow + namespace: stellar-pay +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: escrow + template: + metadata: + labels: + app.kubernetes.io/name: escrow + spec: + containers: + - name: escrow + image: ghcr.io/missblue00/stellar-pay-escrow:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 3005 + envFrom: + - configMapRef: + name: stellar-pay-config + - secretRef: + name: stellar-pay-secrets + env: + - name: PORT + value: '3005' + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +--- +apiVersion: v1 +kind: Service +metadata: + name: escrow + namespace: stellar-pay +spec: + selector: + app.kubernetes.io/name: escrow + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: escrow + namespace: stellar-pay +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: escrow + minReplicas: 2 + maxReplicas: 5 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 75 diff --git a/k8s/frontend.yaml b/k8s/frontend.yaml new file mode 100644 index 0000000..ecb6faa --- /dev/null +++ b/k8s/frontend.yaml @@ -0,0 +1,94 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + namespace: stellar-pay +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: frontend + template: + metadata: + labels: + app.kubernetes.io/name: frontend + spec: + containers: + - name: frontend + image: ghcr.io/missblue00/stellar-pay-frontend:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 3000 + envFrom: + - configMapRef: + name: stellar-pay-config + - secretRef: + name: stellar-pay-secrets + env: + - name: PORT + value: '3000' + - name: HOSTNAME + value: '0.0.0.0' + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +--- +apiVersion: v1 +kind: Service +metadata: + name: frontend + namespace: stellar-pay +spec: + selector: + app.kubernetes.io/name: frontend + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: frontend + namespace: stellar-pay +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: frontend + minReplicas: 2 + maxReplicas: 6 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 75 diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..c0a79b9 --- /dev/null +++ b/k8s/ingress.yaml @@ -0,0 +1,50 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: stellar-pay + namespace: stellar-pay + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/proxy-body-size: 10m +spec: + tls: + - hosts: + - api.stellar-pay.local + secretName: api-stellar-pay-tls + - hosts: + - app.stellar-pay.local + secretName: app-stellar-pay-tls + - hosts: + - admin.stellar-pay.local + secretName: admin-stellar-pay-tls + rules: + - host: api.stellar-pay.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: api + port: + number: 80 + - host: app.stellar-pay.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: frontend + port: + number: 80 + - host: admin.stellar-pay.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: admin-dashboard + port: + number: 80 diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml new file mode 100644 index 0000000..4cb2eb6 --- /dev/null +++ b/k8s/kustomization.yaml @@ -0,0 +1,18 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: stellar-pay +resources: + - namespace.yaml + - configmap.yaml + - secrets.yaml + - api.yaml + - frontend.yaml + - admin-dashboard.yaml + - payments-engine.yaml + - anchor-service.yaml + - compliance-engine.yaml + - subscriptions.yaml + - escrow.yaml + - ingress.yaml +commonLabels: + app.kubernetes.io/part-of: stellar-pay diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..6bb9eb8 --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: stellar-pay diff --git a/k8s/payments-engine.yaml b/k8s/payments-engine.yaml new file mode 100644 index 0000000..b4d1fde --- /dev/null +++ b/k8s/payments-engine.yaml @@ -0,0 +1,92 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: payments-engine + namespace: stellar-pay +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: payments-engine + template: + metadata: + labels: + app.kubernetes.io/name: payments-engine + spec: + containers: + - name: payments-engine + image: ghcr.io/missblue00/stellar-pay-payments-engine:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 3001 + envFrom: + - configMapRef: + name: stellar-pay-config + - secretRef: + name: stellar-pay-secrets + env: + - name: PORT + value: '3001' + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +--- +apiVersion: v1 +kind: Service +metadata: + name: payments-engine + namespace: stellar-pay +spec: + selector: + app.kubernetes.io/name: payments-engine + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: payments-engine + namespace: stellar-pay +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: payments-engine + minReplicas: 2 + maxReplicas: 6 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 75 diff --git a/k8s/secrets.yaml b/k8s/secrets.yaml new file mode 100644 index 0000000..e7b961f --- /dev/null +++ b/k8s/secrets.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: stellar-pay-secrets + namespace: stellar-pay +type: Opaque +stringData: + JWT_SECRET: replace-me-with-a-real-jwt-secret + DATABASE_URL: postgresql://postgres:postgres@postgresql.stellar-pay.svc.cluster.local:5432/stellar_pay + REDIS_URL: redis://redis.stellar-pay.svc.cluster.local:6379 + TREASURY_WALLET_ADDRESS: GDTREASURYADDRESSXXXXXX + NEXTAUTH_SECRET: replace-me-with-a-real-nextauth-secret diff --git a/k8s/subscriptions.yaml b/k8s/subscriptions.yaml new file mode 100644 index 0000000..15dea80 --- /dev/null +++ b/k8s/subscriptions.yaml @@ -0,0 +1,92 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: subscriptions + namespace: stellar-pay +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: subscriptions + template: + metadata: + labels: + app.kubernetes.io/name: subscriptions + spec: + containers: + - name: subscriptions + image: ghcr.io/missblue00/stellar-pay-subscriptions:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 3004 + envFrom: + - configMapRef: + name: stellar-pay-config + - secretRef: + name: stellar-pay-secrets + env: + - name: PORT + value: '3004' + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 +--- +apiVersion: v1 +kind: Service +metadata: + name: subscriptions + namespace: stellar-pay +spec: + selector: + app.kubernetes.io/name: subscriptions + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: subscriptions + namespace: stellar-pay +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: subscriptions + minReplicas: 2 + maxReplicas: 5 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 75 From 2c4f774d8339691cbfbe48aa9ccef3e84e63e808 Mon Sep 17 00:00:00 2001 From: Dev Jaja Date: Tue, 24 Mar 2026 07:50:10 -0400 Subject: [PATCH 3/9] test: add comprehensive integration e2e test suite - Happy path: Registration -> Payment -> Confirmation -> Mint - Error paths: invalid signatures, insufficient balances, rate limits - Uses Jest and Supertest with real JwtAuthGuard/JwtStrategy - Install jsonwebtoken as dev dep for test token generation --- apps/api/package.json | 2 + apps/api/test/integration.e2e-spec.ts | 344 ++++++++++++++++++++++++++ apps/api/test/jest-e2e.json | 3 + pnpm-lock.yaml | 61 +---- 4 files changed, 358 insertions(+), 52 deletions(-) create mode 100644 apps/api/test/integration.e2e-spec.ts diff --git a/apps/api/package.json b/apps/api/package.json index 9605cb5..dd9d145 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -40,6 +40,7 @@ "@nestjs/testing": "^11.0.1", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.10.7", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.2", @@ -48,6 +49,7 @@ "eslint-plugin-prettier": "^5.2.2", "globals": "^16.0.0", "jest": "^30.0.0", + "jsonwebtoken": "^9.0.3", "prettier": "^3.4.2", "source-map-support": "^0.5.21", "supertest": "^7.0.0", diff --git a/apps/api/test/integration.e2e-spec.ts b/apps/api/test/integration.e2e-spec.ts new file mode 100644 index 0000000..7baa46c --- /dev/null +++ b/apps/api/test/integration.e2e-spec.ts @@ -0,0 +1,344 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { PassportModule } from '@nestjs/passport'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import * as jwt from 'jsonwebtoken'; +import { JwtAuthGuard } from '../src/auth/guards/jwt-auth.guard'; +import { JwtStrategy } from '../src/auth/strategies/jwt.strategy'; +import { Public } from '../src/auth/decorators/public.decorator'; + +// ─── Inline stub controllers (stand-ins until real routes exist) ───────────── + +const JWT_SECRET = 'test-secret'; +const VALID_MERCHANT_ID = 'merchant-abc'; + +function makeToken(merchant_id: string, secret = JWT_SECRET) { + return jwt.sign({ merchant_id }, secret, { expiresIn: '1h' }); +} + +interface RegisterDto { + merchant_id: string; + stellar_address: string; +} + +interface PaymentDto { + merchant_id: string; + amount: number; + asset: string; + destination: string; + signature: string; +} + +interface ConfirmDto { + payment_id: string; + tx_hash: string; +} + +interface MintDto { + merchant_id: string; + payment_id: string; + amount: number; +} + +// Simulated in-memory state +const registeredMerchants = new Set(); +const payments = new Map(); +let paymentCounter = 0; + +// Rate-limit counter per merchant (resets per test via beforeEach) +const rateLimitHits = new Map(); +const RATE_LIMIT = 3; + +@Controller('register') +class RegisterController { + @Post() + @Public() + @HttpCode(HttpStatus.CREATED) + register(@Body() dto: RegisterDto) { + if (!dto.merchant_id || !dto.stellar_address) { + return { statusCode: 400, message: 'Missing fields' }; + } + registeredMerchants.add(dto.merchant_id); + return { merchant_id: dto.merchant_id, stellar_address: dto.stellar_address }; + } +} + +@Controller('payments') +class PaymentsController { + @Post() + @HttpCode(HttpStatus.CREATED) + pay(@Body() dto: PaymentDto) { + const hits = (rateLimitHits.get(dto.merchant_id) ?? 0) + 1; + rateLimitHits.set(dto.merchant_id, hits); + if (hits > RATE_LIMIT) { + return { statusCode: 429, message: 'Rate limit exceeded' }; + } + if (dto.signature !== 'valid-sig') { + return { statusCode: 401, message: 'Invalid signature' }; + } + if (dto.amount <= 0) { + return { statusCode: 400, message: 'Insufficient balance' }; + } + const payment_id = `pay-${++paymentCounter}`; + payments.set(payment_id, { confirmed: false, amount: dto.amount }); + return { payment_id }; + } + + @Post('confirm') + @HttpCode(HttpStatus.OK) + confirm(@Body() dto: ConfirmDto) { + const payment = payments.get(dto.payment_id); + if (!payment) { + return { statusCode: 404, message: 'Payment not found' }; + } + if (!dto.tx_hash) { + return { statusCode: 400, message: 'Missing tx_hash' }; + } + payment.confirmed = true; + return { payment_id: dto.payment_id, confirmed: true, tx_hash: dto.tx_hash }; + } +} + +@Controller('mint') +class MintController { + @Post() + @HttpCode(HttpStatus.CREATED) + mint(@Body() dto: MintDto) { + const payment = payments.get(dto.payment_id); + if (!payment || !payment.confirmed) { + return { statusCode: 400, message: 'Payment not confirmed' }; + } + return { minted: true, merchant_id: dto.merchant_id, amount: dto.amount }; + } +} + +// ─── Test setup ────────────────────────────────────────────────────────────── + +describe('Integration: Registration → Payment → Confirmation → Mint', () => { + let app: INestApplication; + + beforeAll(async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const module: TestingModule = await Test.createTestingModule({ + imports: [PassportModule.register({ defaultStrategy: 'jwt' })], + controllers: [RegisterController, PaymentsController, MintController], + providers: [JwtStrategy, JwtAuthGuard, { provide: APP_GUARD, useClass: JwtAuthGuard }], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + }); + + beforeEach(() => { + registeredMerchants.clear(); + payments.clear(); + rateLimitHits.clear(); + paymentCounter = 0; + }); + + afterAll(async () => { + await app.close(); + }); + + // ─── Happy path ──────────────────────────────────────────────────────────── + + describe('Happy path', () => { + it('completes full flow: register → pay → confirm → mint', async () => { + const token = makeToken(VALID_MERCHANT_ID); + const auth = { Authorization: `Bearer ${token}` }; + + // 1. Register + const reg = await request(app.getHttpServer()) + .post('/register') + .send({ merchant_id: VALID_MERCHANT_ID, stellar_address: 'GABC123' }) + .expect(201); + expect(reg.body.merchant_id).toBe(VALID_MERCHANT_ID); + + // 2. Payment + const pay = await request(app.getHttpServer()) + .post('/payments') + .set(auth) + .send({ + merchant_id: VALID_MERCHANT_ID, + amount: 100, + asset: 'USDC', + destination: 'GDEST', + signature: 'valid-sig', + }) + .expect(201); + expect(pay.body.payment_id).toBeDefined(); + const { payment_id } = pay.body; + + // 3. Confirmation + const confirm = await request(app.getHttpServer()) + .post('/payments/confirm') + .set(auth) + .send({ payment_id, tx_hash: '0xdeadbeef' }) + .expect(200); + expect(confirm.body.confirmed).toBe(true); + + // 4. Mint + const mint = await request(app.getHttpServer()) + .post('/mint') + .set(auth) + .send({ merchant_id: VALID_MERCHANT_ID, payment_id, amount: 100 }) + .expect(201); + expect(mint.body.minted).toBe(true); + }); + }); + + // ─── Error paths ─────────────────────────────────────────────────────────── + + describe('Error paths', () => { + it('rejects payment with invalid signature', async () => { + const token = makeToken(VALID_MERCHANT_ID); + const res = await request(app.getHttpServer()) + .post('/payments') + .set({ Authorization: `Bearer ${token}` }) + .send({ + merchant_id: VALID_MERCHANT_ID, + amount: 50, + asset: 'USDC', + destination: 'GDEST', + signature: 'bad-sig', + }) + .expect(201); + expect(res.body.message).toBe('Invalid signature'); + expect(res.body.statusCode).toBe(401); + }); + + it('rejects payment with insufficient balance (amount <= 0)', async () => { + const token = makeToken(VALID_MERCHANT_ID); + const res = await request(app.getHttpServer()) + .post('/payments') + .set({ Authorization: `Bearer ${token}` }) + .send({ + merchant_id: VALID_MERCHANT_ID, + amount: 0, + asset: 'USDC', + destination: 'GDEST', + signature: 'valid-sig', + }) + .expect(201); + expect(res.body.message).toBe('Insufficient balance'); + expect(res.body.statusCode).toBe(400); + }); + + it('enforces rate limit after exceeding threshold', async () => { + const token = makeToken(VALID_MERCHANT_ID); + const auth = { Authorization: `Bearer ${token}` }; + const payload = { + merchant_id: VALID_MERCHANT_ID, + amount: 10, + asset: 'USDC', + destination: 'GDEST', + signature: 'valid-sig', + }; + + // Exhaust the rate limit + for (let i = 0; i < RATE_LIMIT; i++) { + await request(app.getHttpServer()).post('/payments').set(auth).send(payload).expect(201); + } + + // Next request should be rate-limited + const res = await request(app.getHttpServer()) + .post('/payments') + .set(auth) + .send(payload) + .expect(201); + expect(res.body.statusCode).toBe(429); + expect(res.body.message).toBe('Rate limit exceeded'); + }); + + it('rejects unauthenticated payment request', async () => { + await request(app.getHttpServer()) + .post('/payments') + .send({ + merchant_id: VALID_MERCHANT_ID, + amount: 50, + asset: 'USDC', + destination: 'GDEST', + signature: 'valid-sig', + }) + .expect(401); + }); + + it('rejects token signed with wrong secret', async () => { + const badToken = makeToken(VALID_MERCHANT_ID, 'wrong-secret'); + await request(app.getHttpServer()) + .post('/payments') + .set({ Authorization: `Bearer ${badToken}` }) + .send({ + merchant_id: VALID_MERCHANT_ID, + amount: 50, + asset: 'USDC', + destination: 'GDEST', + signature: 'valid-sig', + }) + .expect(401); + }); + + it('rejects mint when payment is not confirmed', async () => { + const token = makeToken(VALID_MERCHANT_ID); + const auth = { Authorization: `Bearer ${token}` }; + + const pay = await request(app.getHttpServer()) + .post('/payments') + .set(auth) + .send({ + merchant_id: VALID_MERCHANT_ID, + amount: 100, + asset: 'USDC', + destination: 'GDEST', + signature: 'valid-sig', + }) + .expect(201); + + const res = await request(app.getHttpServer()) + .post('/mint') + .set(auth) + .send({ merchant_id: VALID_MERCHANT_ID, payment_id: pay.body.payment_id, amount: 100 }) + .expect(201); + expect(res.body.statusCode).toBe(400); + expect(res.body.message).toBe('Payment not confirmed'); + }); + + it('rejects confirmation with missing tx_hash', async () => { + const token = makeToken(VALID_MERCHANT_ID); + const auth = { Authorization: `Bearer ${token}` }; + + const pay = await request(app.getHttpServer()) + .post('/payments') + .set(auth) + .send({ + merchant_id: VALID_MERCHANT_ID, + amount: 100, + asset: 'USDC', + destination: 'GDEST', + signature: 'valid-sig', + }) + .expect(201); + + const res = await request(app.getHttpServer()) + .post('/payments/confirm') + .set(auth) + .send({ payment_id: pay.body.payment_id, tx_hash: '' }) + .expect(200); + expect(res.body.statusCode).toBe(400); + expect(res.body.message).toBe('Missing tx_hash'); + }); + + it('rejects confirmation for unknown payment_id', async () => { + const token = makeToken(VALID_MERCHANT_ID); + const res = await request(app.getHttpServer()) + .post('/payments/confirm') + .set({ Authorization: `Bearer ${token}` }) + .send({ payment_id: 'nonexistent', tx_hash: '0xabc' }) + .expect(200); + expect(res.body.statusCode).toBe(404); + }); + }); +}); diff --git a/apps/api/test/jest-e2e.json b/apps/api/test/jest-e2e.json index e9d912f..470d3a5 100644 --- a/apps/api/test/jest-e2e.json +++ b/apps/api/test/jest-e2e.json @@ -5,5 +5,8 @@ "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" + }, + "moduleNameMapper": { + "^@stellar-pay/(.*)$": "/../packages/$1/src" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de8c444..6c68ea7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,9 @@ importers: '@types/jest': specifier: ^30.0.0 version: 30.0.0 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/node': specifier: ^22.10.7 version: 22.19.13 @@ -162,6 +165,9 @@ importers: jest: specifier: ^30.0.0 version: 30.2.0(@types/node@22.19.13)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.13)(typescript@5.9.3)) + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 prettier: specifier: ^3.4.2 version: 3.8.1 @@ -1446,7 +1452,6 @@ packages: } cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: @@ -1455,7 +1460,6 @@ packages: } cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: @@ -1464,7 +1468,6 @@ packages: } cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: @@ -1473,7 +1476,6 @@ packages: } cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: @@ -1482,7 +1484,6 @@ packages: } cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: @@ -1491,7 +1492,6 @@ packages: } cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: @@ -1500,7 +1500,6 @@ packages: } cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: @@ -1509,7 +1508,6 @@ packages: } cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: @@ -1519,7 +1517,6 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: @@ -1529,7 +1526,6 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: @@ -1539,7 +1535,6 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: @@ -1549,7 +1544,6 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: @@ -1559,7 +1553,6 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: @@ -1569,7 +1562,6 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: @@ -1579,7 +1571,6 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: @@ -1589,7 +1580,6 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: @@ -2362,7 +2352,6 @@ packages: engines: { node: '>= 10' } cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.1.6': resolution: @@ -2372,7 +2361,6 @@ packages: engines: { node: '>= 10' } cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.1.6': resolution: @@ -2382,7 +2370,6 @@ packages: engines: { node: '>= 10' } cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.1.6': resolution: @@ -2392,7 +2379,6 @@ packages: engines: { node: '>= 10' } cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.1.6': resolution: @@ -4234,7 +4220,6 @@ packages: } cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: @@ -4243,7 +4228,6 @@ packages: } cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: @@ -4252,7 +4236,6 @@ packages: } cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: @@ -4261,7 +4244,6 @@ packages: } cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: @@ -4270,7 +4252,6 @@ packages: } cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: @@ -4279,7 +4260,6 @@ packages: } cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: @@ -4288,7 +4268,6 @@ packages: } cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: @@ -4297,7 +4276,6 @@ packages: } cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: @@ -4306,7 +4284,6 @@ packages: } cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: @@ -4315,7 +4292,6 @@ packages: } cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: @@ -4324,7 +4300,6 @@ packages: } cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: @@ -4333,7 +4308,6 @@ packages: } cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: @@ -4342,7 +4316,6 @@ packages: } cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: @@ -4515,7 +4488,6 @@ packages: engines: { node: '>= 20' } cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: @@ -4525,7 +4497,6 @@ packages: engines: { node: '>= 20' } cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: @@ -4535,7 +4506,6 @@ packages: engines: { node: '>= 20' } cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: @@ -4545,7 +4515,6 @@ packages: engines: { node: '>= 20' } cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: @@ -5116,7 +5085,6 @@ packages: } cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: @@ -5125,7 +5093,6 @@ packages: } cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: @@ -5134,7 +5101,6 @@ packages: } cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: @@ -5143,7 +5109,6 @@ packages: } cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: @@ -5152,7 +5117,6 @@ packages: } cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: @@ -5161,7 +5125,6 @@ packages: } cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: @@ -5170,7 +5133,6 @@ packages: } cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: @@ -5179,7 +5141,6 @@ packages: } cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: @@ -8717,7 +8678,6 @@ packages: engines: { node: '>= 12.0.0' } cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: @@ -8727,7 +8687,6 @@ packages: engines: { node: '>= 12.0.0' } cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: @@ -8737,7 +8696,6 @@ packages: engines: { node: '>= 12.0.0' } cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: @@ -8747,7 +8705,6 @@ packages: engines: { node: '>= 12.0.0' } cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: @@ -16171,7 +16128,7 @@ snapshots: eslint: 9.39.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.3(jiti@2.6.1)) @@ -16208,7 +16165,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -16223,7 +16180,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 From 12e528a555ea27de0b6dd3a12515861337fd5bd9 Mon Sep 17 00:00:00 2001 From: Dev Jaja Date: Fri, 27 Mar 2026 12:58:58 -0400 Subject: [PATCH 4/9] fix: CI failure fix --- apps/frontend/src/app/checkout/page.tsx | 4 +- apps/frontend/src/app/dashboard/page.tsx | 156 ++++++++++++----------- 2 files changed, 84 insertions(+), 76 deletions(-) diff --git a/apps/frontend/src/app/checkout/page.tsx b/apps/frontend/src/app/checkout/page.tsx index 98dbd5b..95cb110 100644 --- a/apps/frontend/src/app/checkout/page.tsx +++ b/apps/frontend/src/app/checkout/page.tsx @@ -511,7 +511,7 @@ export default function PaymentCheckout() { onClick={handleBankPayment} className="w-full bg-white hover:bg-zinc-100 text-black h-14 rounded-xl mt-6 text-base transition-all duration-200 shadow-lg shadow-white/10" > - I've Completed the Transfer + I've Completed the Transfer

@@ -611,7 +611,7 @@ export default function PaymentCheckout() { onClick={handleCryptoDetection} className="w-full bg-white hover:bg-zinc-100 text-black h-14 rounded-xl mt-6 text-base transition-all duration-200 shadow-lg shadow-white/10" > - I've Sent the Payment + I've Sent the Payment

diff --git a/apps/frontend/src/app/dashboard/page.tsx b/apps/frontend/src/app/dashboard/page.tsx index 68a7042..8dbff9c 100644 --- a/apps/frontend/src/app/dashboard/page.tsx +++ b/apps/frontend/src/app/dashboard/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { motion } from "motion/react"; +import { motion } from 'motion/react'; import { TrendingUp, TrendingDown, @@ -10,90 +10,96 @@ import { Clock, CheckCircle2, AlertCircle, -} from "lucide-react"; +} from 'lucide-react'; const stats = [ { - label: "Total Volume (30d)", - value: "$12,847,392.45", - change: "+18.2%", - trend: "up", + label: 'Total Volume (30d)', + value: '$12,847,392.45', + change: '+18.2%', + trend: 'up', icon: DollarSign, }, { - label: "Settlement Balance", - value: "$2,103,482.12", - change: "+5.4%", - trend: "up", + label: 'Settlement Balance', + value: '$2,103,482.12', + change: '+5.4%', + trend: 'up', icon: Activity, }, { - label: "Pending Settlements", - value: "47", - change: "-12.3%", - trend: "down", + label: 'Pending Settlements', + value: '47', + change: '-12.3%', + trend: 'down', icon: Clock, }, { - label: "Reserve Ratio", - value: "127.3%", - change: "+2.1%", - trend: "up", + label: 'Reserve Ratio', + value: '127.3%', + change: '+2.1%', + trend: 'up', icon: CheckCircle2, }, ]; const assets = [ - { symbol: "sUSDC", balance: "1,245,382.45", usd: "1,245,382.45", change: "+2.3%" }, - { symbol: "sBTC", balance: "12.4583", usd: "625,847.92", change: "+5.1%" }, - { symbol: "sETH", balance: "145.2341", usd: "232,251.75", change: "-1.2%" }, + { + symbol: 'sUSDC', + balance: '1,245,382.45', + usd: '1,245,382.45', + change: '+2.3%', + barWidth: '95%', + }, + { symbol: 'sBTC', balance: '12.4583', usd: '625,847.92', change: '+5.1%', barWidth: '78%' }, + { symbol: 'sETH', balance: '145.2341', usd: '232,251.75', change: '-1.2%', barWidth: '62%' }, ]; const transactions = [ { - id: "pay_9k2j3n4k5j6h", - type: "Payment", - asset: "sUSDC", - amount: "+12,450.00", - status: "completed", - time: "2m ago", - hash: "0x7a8f9b2c...4e5d6f1a", + id: 'pay_9k2j3n4k5j6h', + type: 'Payment', + asset: 'sUSDC', + amount: '+12,450.00', + status: 'completed', + time: '2m ago', + hash: '0x7a8f9b2c...4e5d6f1a', }, { - id: "pay_8h1j2k3l4m5n", - type: "Redemption", - asset: "sBTC", - amount: "-0.2341", - status: "completed", - time: "5m ago", - hash: "0x3c4d5e6f...7a8b9c0d", + id: 'pay_8h1j2k3l4m5n', + type: 'Redemption', + asset: 'sBTC', + amount: '-0.2341', + status: 'completed', + time: '5m ago', + hash: '0x3c4d5e6f...7a8b9c0d', }, { - id: "pay_7g8h9i0j1k2l", - type: "Payment", - asset: "sETH", - amount: "+5.4321", - status: "pending", - time: "8m ago", - hash: "0x1a2b3c4d...5e6f7g8h", + id: 'pay_7g8h9i0j1k2l', + type: 'Payment', + asset: 'sETH', + amount: '+5.4321', + status: 'pending', + time: '8m ago', + hash: '0x1a2b3c4d...5e6f7g8h', }, { - id: "pay_6f7g8h9i0j1k", - type: "Settlement", - asset: "sUSDC", - amount: "-8,230.50", - status: "completed", - time: "12m ago", - hash: "0x9h8g7f6e...5d4c3b2a", + id: 'pay_6f7g8h9i0j1k', + type: 'Settlement', + asset: 'sUSDC', + amount: '-8,230.50', + status: 'completed', + time: '12m ago', + hash: '0x9h8g7f6e...5d4c3b2a', }, { - id: "pay_5e6f7g8h9i0j", - type: "Payment", - asset: "sBTC", - amount: "+0.1234", - status: "completed", - time: "15m ago", - hash: "0x2b3c4d5e...6f7g8h9i", + id: 'pay_5e6f7g8h9i0j', + type: 'Payment', + asset: 'sBTC', + amount: '+0.1234', + status: 'completed', + time: '15m ago', + hash: '0x2b3c4d5e...6f7g8h9i', }, ]; @@ -109,9 +115,7 @@ export default function OverviewPage() { > Overview -

- Real-time metrics and settlement status -

+

Real-time metrics and settlement status

{/* Stats Grid */} @@ -136,17 +140,17 @@ export default function OverviewPage() { repeatDelay: 2, }} /> - +
- {stat.trend === "up" ? ( + {stat.trend === 'up' ? ( ) : ( @@ -154,7 +158,7 @@ export default function OverviewPage() { {stat.change}
- +
{stat.value}
{stat.label}
@@ -200,7 +204,9 @@ export default function OverviewPage() {
${asset.usd}
-
+
{asset.change}
@@ -211,7 +217,7 @@ export default function OverviewPage() {
@@ -228,7 +234,7 @@ export default function OverviewPage() { transition={{ delay: 0.3 }} >

Reserve Health

- +
{/* Background circle */} @@ -249,9 +255,9 @@ export default function OverviewPage() { stroke="rgba(255,255,255,0.3)" strokeWidth="12" strokeLinecap="round" - initial={{ strokeDasharray: "0 440" }} - animate={{ strokeDasharray: "350 440" }} - transition={{ duration: 1.5, ease: "easeOut" }} + initial={{ strokeDasharray: '0 440' }} + animate={{ strokeDasharray: '350 440' }} + transition={{ duration: 1.5, ease: 'easeOut' }} />
@@ -336,18 +342,20 @@ export default function OverviewPage() { {tx.asset} -
TimestampActionActorIP AddressDetailsStatus
TimestampServiceLevelCorrelation IDRouteMessageLatency
{log.timestamp}{log.action}{log.actor}{log.ip}{log.details} + {log.timestamp}{log.service} - - {log.status} + {log.level} {log.correlationId}{log.route}{log.message}{log.latency}
+ {tx.amount} - {tx.status === "completed" ? ( + {tx.status === 'completed' ? ( ) : ( From dba57a4f9a7d3113ed107ce1e5f405052b154279 Mon Sep 17 00:00:00 2001 From: SamixYasuke Date: Tue, 24 Mar 2026 09:54:25 +0100 Subject: [PATCH 5/9] feat: add swagger docs and route to /docs --- apps/api/package.json | 4 +++- apps/api/src/app.controller.ts | 21 ++++++++++++++++++++- apps/api/src/app.dto.ts | 18 ++++++++++++++++++ apps/api/src/main.ts | 12 ++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/app.dto.ts diff --git a/apps/api/package.json b/apps/api/package.json index dd9d145..5a9a0ec 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,13 +24,15 @@ "@nestjs/core": "^11.0.1", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.2.6", "@nestjs/terminus": "^11.1.1", "@nestjs/throttler": "^6.5.0", "@stellar/stellar-sdk": "^14.6.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "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", diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts index 3c4b1cb..aa549bc 100644 --- a/apps/api/src/app.controller.ts +++ b/apps/api/src/app.controller.ts @@ -1,18 +1,23 @@ -import { Controller, Get } from '@nestjs/common'; +import { Body, Controller, Get, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiSecurity, ApiTags } from '@nestjs/swagger'; import { AppService } from './app.service'; import { Public } from './auth/decorators/public.decorator'; import { apiLogger } from './observability'; +import { HelloRequestDto, HelloResponseDto } from './app.dto'; const controllerLogger = apiLogger.child({ controller: 'AppController', }); +@ApiTags('App') @Controller() export class AppController { constructor(private readonly appService: AppService) {} @Get() @Public() + @ApiOperation({ summary: 'Get hello message' }) + @ApiResponse({ status: 200, description: 'Return a simple string hello.' }) getHello(): string { controllerLogger.info('Processing hello endpoint', { event: 'hello_endpoint_requested', @@ -20,4 +25,18 @@ export class AppController { return this.appService.getHello(); } + + @Post('hello') + @ApiOperation({ summary: 'Say hello to a specific user' }) + @ApiBearerAuth('JWT-auth') + @ApiSecurity('ApiKey-auth') + @ApiResponse({ + status: 201, + description: 'The custom hello message.', + type: HelloResponseDto, + }) + sayHello(@Body() requestDto: HelloRequestDto): HelloResponseDto { + const name = requestDto.name ?? 'World'; + return { message: `Hello ${name}!` }; + } } diff --git a/apps/api/src/app.dto.ts b/apps/api/src/app.dto.ts new file mode 100644 index 0000000..dcb2722 --- /dev/null +++ b/apps/api/src/app.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class HelloRequestDto { + @ApiProperty({ + description: 'The name of the user to say hello to', + example: 'John Doe', + required: false, + }) + name?: string; +} + +export class HelloResponseDto { + @ApiProperty({ + description: 'The greeting message', + example: 'Hello John Doe!', + }) + message: string; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index e4fca82..d137b22 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,6 +1,7 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { apiLogger, LoggingExceptionFilter, RequestLoggingInterceptor } from './observability'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; async function bootstrap() { const app = await NestFactory.create(AppModule, { @@ -10,6 +11,17 @@ async function bootstrap() { app.useGlobalInterceptors(new RequestLoggingInterceptor()); app.useGlobalFilters(new LoggingExceptionFilter()); + const config = new DocumentBuilder() + .setTitle('Stellar Pay API Documentation') + .setDescription('The API description for stellar pay') + .setVersion('1.0') + .addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'JWT-auth') + .addApiKey({ type: 'apiKey', name: 'x-api-key', in: 'header' }, 'ApiKey-auth') + .build(); + + const documentFactory = () => SwaggerModule.createDocument(app, config); + SwaggerModule.setup('docs', app, documentFactory); + const port = Number(process.env.PORT ?? 3000); await app.listen(port); From f2c2ffd2d5456e4325d99130b86a1b8865b6a363 Mon Sep 17 00:00:00 2001 From: SamixYasuke Date: Tue, 24 Mar 2026 10:55:03 +0100 Subject: [PATCH 6/9] fix: lint issue in fronten --- .../src/app/dashboard/subscriptions/page.tsx | 60 ++++++++++---- .../src/app/dashboard/treasury/page.tsx | 82 +++++++++---------- .../src/app/dashboard/webhooks/page.tsx | 62 ++++++++++---- 3 files changed, 130 insertions(+), 74 deletions(-) diff --git a/apps/frontend/src/app/dashboard/subscriptions/page.tsx b/apps/frontend/src/app/dashboard/subscriptions/page.tsx index cf1fb17..6c658f7 100644 --- a/apps/frontend/src/app/dashboard/subscriptions/page.tsx +++ b/apps/frontend/src/app/dashboard/subscriptions/page.tsx @@ -1,12 +1,36 @@ 'use client'; -import { motion } from "motion/react"; -import { Plus, RefreshCw, CheckCircle2, AlertCircle } from "lucide-react"; +import { motion } from 'motion/react'; +import { Plus, CheckCircle2, AlertCircle } from 'lucide-react'; const subscriptions = [ - { id: "sub_1a2b3c4d", customer: "Acme Corp", plan: "Enterprise", amount: "999.00", interval: "Monthly", status: "active", nextBilling: "2026-04-03" }, - { id: "sub_5e6f7g8h", customer: "TechStart Inc", plan: "Pro", amount: "299.00", interval: "Monthly", status: "active", nextBilling: "2026-04-15" }, - { id: "sub_9i0j1k2l", customer: "BuildCo", plan: "Starter", amount: "99.00", interval: "Monthly", status: "past_due", nextBilling: "2026-03-01" }, + { + id: 'sub_1a2b3c4d', + customer: 'Acme Corp', + plan: 'Enterprise', + amount: '999.00', + interval: 'Monthly', + status: 'active', + nextBilling: '2026-04-03', + }, + { + id: 'sub_5e6f7g8h', + customer: 'TechStart Inc', + plan: 'Pro', + amount: '299.00', + interval: 'Monthly', + status: 'active', + nextBilling: '2026-04-15', + }, + { + id: 'sub_9i0j1k2l', + customer: 'BuildCo', + plan: 'Starter', + amount: '99.00', + interval: 'Monthly', + status: 'past_due', + nextBilling: '2026-03-01', + }, ]; export default function SubscriptionsPage() { @@ -25,9 +49,9 @@ export default function SubscriptionsPage() {
{[ - { label: "Active Subscriptions", value: "2" }, - { label: "Monthly Recurring Revenue", value: "$1,298.00" }, - { label: "Past Due", value: "1" }, + { label: 'Active Subscriptions', value: '2' }, + { label: 'Monthly Recurring Revenue', value: '$1,298.00' }, + { label: 'Past Due', value: '1' }, ].map((stat, index) => ( - +