From a58e5d8ea904d9bbc69ac422ed3b829926ea9de2 Mon Sep 17 00:00:00 2001 From: zakkiyyat Date: Sat, 30 May 2026 15:48:30 +0100 Subject: [PATCH 1/3] Add session approve/refund wrappers, metrics endpoint, and demo seed command --- backend/package.json | 3 +- backend/src/app.module.ts | 5 +- backend/src/database/seeds/demo-seed.ts | 60 ++++++++++++++++++++ backend/src/monitoring/metrics.controller.ts | 13 +++++ backend/src/monitoring/metrics.middleware.ts | 13 +++++ backend/src/monitoring/metrics.service.ts | 32 +++++++++++ backend/src/monitoring/monitoring.module.ts | 10 ++++ contract/src/lib.rs | 12 +++- 8 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 backend/src/database/seeds/demo-seed.ts create mode 100644 backend/src/monitoring/metrics.controller.ts create mode 100644 backend/src/monitoring/metrics.middleware.ts create mode 100644 backend/src/monitoring/metrics.service.ts create mode 100644 backend/src/monitoring/monitoring.module.ts diff --git a/backend/package.json b/backend/package.json index d7a98e1a9..9f61a4fe7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,7 +21,8 @@ "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js", "migration:generate": "npm run typeorm -- migration:generate -d src/database/data-source.ts", "migration:run": "npm run typeorm -- migration:run -d src/database/data-source.ts", - "migration:revert": "npm run typeorm -- migration:revert -d src/database/data-source.ts" + "migration:revert": "npm run typeorm -- migration:revert -d src/database/data-source.ts", + "seed:demo": "SEED_DEMO_DATA=true ts-node -r tsconfig-paths/register src/database/seeds/demo-seed.ts" }, "dependencies": { "@nestjs/common": "^11.0.1", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 5652e5d31..3dc75c25e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -13,6 +13,8 @@ import { PortfolioModule } from './portfolio/portfolio.module'; import { AvailabilityModule } from './availability/availability.module'; import { AvatarModule } from './avatar/avatar.module'; import { AdminModule } from './admin/admin.module'; +import { MonitoringModule } from './monitoring/monitoring.module'; +import { MetricsMiddleware } from './monitoring/metrics.middleware'; @Module({ imports: [ @@ -29,12 +31,13 @@ import { AdminModule } from './admin/admin.module'; PortfolioModule, AvailabilityModule, AvatarModule, + MonitoringModule, ], controllers: [AppController], providers: [AppService], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer): void { - consumer.apply(HttpLoggerMiddleware).forRoutes('*'); + consumer.apply(HttpLoggerMiddleware, MetricsMiddleware).forRoutes('*'); } } diff --git a/backend/src/database/seeds/demo-seed.ts b/backend/src/database/seeds/demo-seed.ts new file mode 100644 index 000000000..f7c49ebfd --- /dev/null +++ b/backend/src/database/seeds/demo-seed.ts @@ -0,0 +1,60 @@ +import 'reflect-metadata'; +import { AppDataSource } from '../../database/data-source'; +import { User } from '../../users/entities/user.entity'; +import { Role } from '../../users/entities/role.entity'; + +async function seedDemo() { + if (process.env.SEED_DEMO_DATA !== 'true') { + console.log('SEED_DEMO_DATA is not true; skipping demo seed.'); + return; + } + + await AppDataSource.initialize(); + const roleRepo = AppDataSource.getRepository(Role); + const userRepo = AppDataSource.getRepository(User); + + const mentorRole = + (await roleRepo.findOne({ where: { name: 'mentor' } })) ?? + (await roleRepo.save(roleRepo.create({ name: 'mentor', description: 'Demo mentor role' }))); + const menteeRole = + (await roleRepo.findOne({ where: { name: 'mentee' } })) ?? + (await roleRepo.save(roleRepo.create({ name: 'mentee', description: 'Demo mentee role' }))); + + for (let i = 1; i <= 5; i++) { + const mentorWallet = `demo_mentor_${i}@example.com`; + const menteeWallet = `demo_mentee_${i}@example.com`; + + const mentorExists = await userRepo.findOne({ where: { walletAddress: mentorWallet } }); + if (!mentorExists) { + await userRepo.save( + userRepo.create({ + walletAddress: mentorWallet, + displayName: `Demo Mentor ${i}`, + username: `demo_mentor_${i}`, + roles: [mentorRole], + }), + ); + } + + const menteeExists = await userRepo.findOne({ where: { walletAddress: menteeWallet } }); + if (!menteeExists) { + await userRepo.save( + userRepo.create({ + walletAddress: menteeWallet, + displayName: `Demo Mentee ${i}`, + username: `demo_mentee_${i}`, + roles: [menteeRole], + }), + ); + } + } + + console.log('Demo mentor/mentee accounts seeded.'); + await AppDataSource.destroy(); +} + +seedDemo().catch(async (e) => { + console.error(e); + if (AppDataSource.isInitialized) await AppDataSource.destroy(); + process.exit(1); +}); diff --git a/backend/src/monitoring/metrics.controller.ts b/backend/src/monitoring/metrics.controller.ts new file mode 100644 index 000000000..7444176b8 --- /dev/null +++ b/backend/src/monitoring/metrics.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, Header } from '@nestjs/common'; +import { MetricsService } from './metrics.service'; + +@Controller() +export class MetricsController { + constructor(private readonly metrics: MetricsService) {} + + @Get('metrics') + @Header('Content-Type', 'text/plain; version=0.0.4') + getMetrics() { + return this.metrics.toPrometheus(); + } +} diff --git a/backend/src/monitoring/metrics.middleware.ts b/backend/src/monitoring/metrics.middleware.ts new file mode 100644 index 000000000..2ba4e949d --- /dev/null +++ b/backend/src/monitoring/metrics.middleware.ts @@ -0,0 +1,13 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { MetricsService } from './metrics.service'; + +@Injectable() +export class MetricsMiddleware implements NestMiddleware { + constructor(private readonly metrics: MetricsService) {} + + use(_req: Request, _res: Response, next: NextFunction) { + this.metrics.recordRequest(); + next(); + } +} diff --git a/backend/src/monitoring/metrics.service.ts b/backend/src/monitoring/metrics.service.ts new file mode 100644 index 000000000..bf8e31a0a --- /dev/null +++ b/backend/src/monitoring/metrics.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class MetricsService { + private requestsTotal = 0; + private jwtVerificationFailures = 0; + + recordRequest() { + this.requestsTotal += 1; + } + + recordJwtVerificationFailure() { + this.jwtVerificationFailures += 1; + } + + toPrometheus(): string { + return [ + '# HELP http_requests_total Total HTTP requests handled', + '# TYPE http_requests_total counter', + `http_requests_total ${this.requestsTotal}`, + '# HELP http_request_duration_seconds HTTP request duration histogram placeholder', + '# TYPE http_request_duration_seconds histogram', + 'http_request_duration_seconds_bucket{le="1"} 0', + 'http_request_duration_seconds_bucket{le="+Inf"} 0', + 'http_request_duration_seconds_count 0', + 'http_request_duration_seconds_sum 0', + '# HELP jwt_verification_failures_total JWT verification failures', + '# TYPE jwt_verification_failures_total counter', + `jwt_verification_failures_total ${this.jwtVerificationFailures}`, + ].join('\n'); + } +} diff --git a/backend/src/monitoring/monitoring.module.ts b/backend/src/monitoring/monitoring.module.ts new file mode 100644 index 000000000..7a6403954 --- /dev/null +++ b/backend/src/monitoring/monitoring.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MetricsController } from './metrics.controller'; +import { MetricsService } from './metrics.service'; + +@Module({ + controllers: [MetricsController], + providers: [MetricsService], + exports: [MetricsService], +}) +export class MonitoringModule {} diff --git a/contract/src/lib.rs b/contract/src/lib.rs index c6aa30f2f..bd15e8d78 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -209,6 +209,11 @@ impl EscrowContract { } s.state = SessionState::Approved; env.storage().persistent().set(&DataKey::Session(session_id), &s); + env.events().publish((Symbol::new(&env, "SessionApproved"),), session_id); + } + + pub fn approve_session(env: Env, session_id: u64, token_id: Address) { + Self::approve(env, session_id, token_id); } pub fn refund(env: Env, session_id: u64, token_id: Address) { @@ -219,6 +224,11 @@ impl EscrowContract { s.state = SessionState::Refunded; env.storage().persistent().set(&DataKey::Session(session_id), &s); env.events().publish((symbol_short!("REFUNDED"), session_id), s.amount); + env.events().publish((Symbol::new(&env, "SessionRefunded"),), session_id); + } + + pub fn refund_session(env: Env, session_id: u64, token_id: Address) { + Self::refund(env, session_id, token_id); } pub fn auto_refund(env: Env, session_id: u64, token_id: Address) { @@ -606,4 +616,4 @@ impl SkillSyncEscrow { } #[cfg(test)] -mod test; \ No newline at end of file +mod test; From 1962e0f7dde0c53473168206cabc451bc98b7bbb Mon Sep 17 00:00:00 2001 From: chemicalcommando Date: Sat, 30 May 2026 16:07:48 +0100 Subject: [PATCH 2/3] Implement minimal escrow dispute, auto-refund, and fee flow --- contracts/escrow/src/lib.rs | 127 ++++++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 7 deletions(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 837318094..611f80b01 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Bytes32, Env, Symbol}; +use soroban_sdk::{contract, contractimpl, contracttype, Address, Bytes32, Env, Symbol, String}; #[contract] pub struct EscrowContract; @@ -13,11 +13,37 @@ pub struct Session { pub seller: Address, pub amount: i128, pub timestamp: u64, + pub status: u32, + pub completed_at: u64, + pub dispute_opened_at: u64, } +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Session(Bytes32), + Admin, + Treasury, + FeeBps, + DisputeWindow, +} + +const STATUS_LOCKED: u32 = 0; +const STATUS_COMPLETED: u32 = 1; +const STATUS_DISPUTED: u32 = 2; +const STATUS_REFUNDED: u32 = 3; +const STATUS_RESOLVED: u32 = 4; + #[contractimpl] impl EscrowContract { - /// Locks funds into a new escrow session and emits the FundsLocked event. + pub fn init_config(e: Env, admin: Address, treasury: Address, fee_bps: u32, dispute_window: u64) { + admin.require_auth(); + e.storage().persistent().set(&DataKey::Admin, &admin); + e.storage().persistent().set(&DataKey::Treasury, &treasury); + e.storage().persistent().set(&DataKey::FeeBps, &fee_bps); + e.storage().persistent().set(&DataKey::DisputeWindow, &dispute_window); + } + pub fn lock_funds( e: Env, session_id: Bytes32, @@ -27,21 +53,108 @@ impl EscrowContract { ) { buyer.require_auth(); - let timestamp = e.ledger().timestamp(); + let now = e.ledger().timestamp(); let session_metadata = Session { id: session_id.clone(), buyer: buyer.clone(), seller: seller.clone(), amount, - timestamp, + timestamp: now, + status: STATUS_LOCKED, + completed_at: 0, + dispute_opened_at: 0, }; + e.storage().persistent().set(&DataKey::Session(session_id.clone()), &session_metadata); - // Emit the FundsLocked event as specified in the acceptance criteria - // Signature: event FundsLocked(session_id: Bytes32, buyer: Address, seller: Address, amount: i128, timestamp: u64) e.events().publish( (Symbol::new(&e, "FundsLocked"),), - (session_id, buyer, seller, amount, timestamp), + (session_id, buyer, seller, amount, now), + ); + } + + pub fn complete_session(e: Env, session_id: Bytes32) { + let mut session: Session = e + .storage() + .persistent() + .get(&DataKey::Session(session_id.clone())) + .unwrap(); + session.status = STATUS_COMPLETED; + session.completed_at = e.ledger().timestamp(); + e.storage().persistent().set(&DataKey::Session(session_id), &session); + } + + pub fn open_dispute(e: Env, session_id: Bytes32, reason: String) { + let mut session: Session = e + .storage() + .persistent() + .get(&DataKey::Session(session_id.clone())) + .unwrap(); + + if session.status != STATUS_COMPLETED && session.status != STATUS_LOCKED { + panic!("session not disputable"); + } + + session.status = STATUS_DISPUTED; + session.dispute_opened_at = e.ledger().timestamp(); + e.storage().persistent().set(&DataKey::Session(session_id.clone()), &session); + e.events().publish((Symbol::new(&e, "DisputeOpened"),), (session_id, reason)); + } + + pub fn resolve_dispute( + e: Env, + session_id: Bytes32, + resolution: u32, + buyer_share: i128, + seller_share: i128, + ) { + let mut session: Session = e + .storage() + .persistent() + .get(&DataKey::Session(session_id.clone())) + .unwrap(); + if session.status != STATUS_DISPUTED { + panic!("session not disputed"); + } + let admin: Address = e.storage().persistent().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + + if buyer_share + seller_share != session.amount { + panic!("invalid shares"); + } + let (seller_after_fee, fee_amount) = Self::apply_fee(&e, seller_share); + let buyer_after_fee = buyer_share; + + session.status = STATUS_RESOLVED; + e.storage().persistent().set(&DataKey::Session(session_id.clone()), &session); + e.events().publish( + (Symbol::new(&e, "DisputeResolved"),), + (session_id, resolution, buyer_after_fee, seller_after_fee, fee_amount), ); } + + pub fn auto_refund(e: Env, session_id: Bytes32) { + let mut session: Session = e + .storage() + .persistent() + .get(&DataKey::Session(session_id.clone())) + .unwrap(); + if session.status != STATUS_COMPLETED { + panic!("session not completed"); + } + let dispute_window: u64 = e.storage().persistent().get(&DataKey::DisputeWindow).unwrap_or(0); + if e.ledger().timestamp() <= session.completed_at + dispute_window { + panic!("dispute window not elapsed"); + } + + session.status = STATUS_REFUNDED; + e.storage().persistent().set(&DataKey::Session(session_id.clone()), &session); + e.events().publish((Symbol::new(&e, "AutoRefundExecuted"),), (session_id, session.amount)); + } + + fn apply_fee(e: &Env, amount: i128) -> (i128, i128) { + let fee_bps: u32 = e.storage().persistent().get(&DataKey::FeeBps).unwrap_or(0); + let fee = (amount * (fee_bps as i128)) / 10_000; + (amount - fee, fee) + } } From af1918ab254a4b53fcbbea1586a74dc07172890c Mon Sep 17 00:00:00 2001 From: nottherealalanturing Date: Sat, 30 May 2026 16:17:32 +0100 Subject: [PATCH 3/3] Harden runtime config with CORS, Helmet, and richer health checks --- backend/package.json | 1 + backend/src/health/health.controller.ts | 13 +++++++++-- backend/src/main.ts | 29 ++++++++++++++++++++++++- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/backend/package.json b/backend/package.json index 9f61a4fe7..abd24ae93 100644 --- a/backend/package.json +++ b/backend/package.json @@ -32,6 +32,7 @@ "@nestjs/swagger": "^8.1.0", "@nestjs/terminus": "^11.1.1", "@nestjs/typeorm": "^11.0.0", + "helmet": "^8.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "ioredis": "^5.11.0", diff --git a/backend/src/health/health.controller.ts b/backend/src/health/health.controller.ts index bcc2c6f96..fe38f9eea 100644 --- a/backend/src/health/health.controller.ts +++ b/backend/src/health/health.controller.ts @@ -1,17 +1,26 @@ import { Controller, Get } from '@nestjs/common'; import { HealthCheck, HealthCheckService } from '@nestjs/terminus'; import { RedisHealthIndicator } from '../redis/redis.health'; +import { DataSource } from 'typeorm'; @Controller('health') export class HealthController { constructor( private readonly health: HealthCheckService, private readonly redis: RedisHealthIndicator, + private readonly dataSource: DataSource, ) {} @Get() @HealthCheck() - check() { - return this.health.check([() => this.redis.isHealthy('redis')]); + async check() { + return this.health.check([ + async () => { + await this.dataSource.query('SELECT 1'); + return { database: { status: 'up' } }; + }, + () => this.redis.isHealthy('redis'), + async () => ({ memory: { status: 'up', heapUsed: process.memoryUsage().heapUsed } }), + ]); } } diff --git a/backend/src/main.ts b/backend/src/main.ts index 1f735df9d..f5e220b4b 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,11 +1,11 @@ import { HttpStatus, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import { ValidationPipe } from '@nestjs/common'; import { AppModule } from './app.module'; import { DataSource } from 'typeorm'; import { ApiValidationException } from './common/exceptions/api-exceptions'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; +import helmet from 'helmet'; async function bootstrap() { const app = await NestFactory.create(AppModule, { @@ -15,6 +15,33 @@ async function bootstrap() { ? ['error', 'warn'] : ['log', 'error', 'warn', 'debug', 'verbose'], }); + app.disable('x-powered-by'); + app.set('trust proxy', process.env.TRUST_PROXY === 'true'); + + const allowedOrigins = + process.env.CORS_ORIGINS?.split(',').map((o) => o.trim()).filter(Boolean) ?? []; + app.enableCors({ + origin: (origin, callback) => { + if (!origin) return callback(null, true); + if (process.env.NODE_ENV !== 'production' && /^https?:\/\/localhost(:\d+)?$/.test(origin)) { + return callback(null, true); + } + if (allowedOrigins.includes(origin)) return callback(null, true); + return callback(new Error('Not allowed by CORS'), false); + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Authorization', 'Content-Type', 'Accept'], + optionsSuccessStatus: 204, + }); + app.use( + helmet({ + hsts: process.env.NODE_ENV === 'production' ? { maxAge: 31_536_000 } : false, + frameguard: { action: 'deny' }, + noSniff: true, + xssFilter: true, + }), + ); app.useGlobalPipes( new ValidationPipe({