diff --git a/src/app.module.ts b/src/app.module.ts index 4f72eee..04dc439 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,7 +16,7 @@ import { ProfilesModule } from './profiles/profiles.module'; import { WalletsModule } from './wallets/wallets.module'; import { EventsModule } from './events/events.module'; import { WebhooksModule } from './webhooks/webhooks.module'; -import { KybModule } from './kyb/kyb.module'; +import { VerificationModule } from './verification/verification.module'; @Module({ imports: [ @@ -36,7 +36,7 @@ import { KybModule } from './kyb/kyb.module'; WalletsModule, EventsModule, WebhooksModule, - KybModule, + VerificationModule, ], controllers: [RootController], }) diff --git a/src/integration/verification.integration.spec.ts b/src/integration/verification.integration.spec.ts new file mode 100644 index 0000000..4c69e6b --- /dev/null +++ b/src/integration/verification.integration.spec.ts @@ -0,0 +1,550 @@ +import 'reflect-metadata'; +import { ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import type { INestApplication } from '@nestjs/common'; +import * as jwt from 'jsonwebtoken'; +import request from 'supertest'; +import { VerificationService } from '../verification/verification.service'; +import { VerificationProviderFactory } from '../verification/providers/provider-factory'; +import { VerificationController } from '../verification/verification.controller'; +import { + IVerificationProvider, + ProviderConfig, +} from '../verification/providers/verification.provider'; +import { + VerificationStatus, + VerificationType, + VerificationProvider, +} from '../verification/dto/verification.dto'; +import { SupabaseService } from '../supabase/supabase.service'; +import { AuthModule } from '../auth/auth.module'; +import { WalletsService } from '../wallets/wallets.service'; + +// Set environment variables for Supabase (needed for tests) +process.env.SUPABASE_URL = 'https://test.supabase.co'; +process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-role-key'; + +// Mock stellar-sdk to avoid ES module issues +jest.mock('@stellar/stellar-sdk', () => ({ + Keypair: { + fromPublicKey: () => ({ verify: () => true }), + }, +})); + +type Row = Record; +type QueryResult = { data?: any; error: { message: string; code?: string } | null; count?: number }; + +const JWT_SECRET = 'dev-insecure-change-me'; +const USER_ID = 'staging-user-1'; +const WALLET = 'GSTAGINGUSERWALLET000000000000000000000000000000000000000'; + +class InMemorySupabase { + readonly tables: Record = {}; + private sequence = 1; + + constructor() { + this.reset(); + } + + reset() { + this.sequence = 1; + this.tables.verison_sessions = []; + this.tables.user_wallets = []; + this.tables.agreement_participants = []; + } + + getClient() { + return { + from: (table: string) => new QueryBuilder(this, table), + }; + } + + insert(table: string, row: Row) { + const inserted = { + id: row.id ?? `${table}-${this.sequence++}`, + created_at: row.created_at ?? '2026-06-29T00:00:00.000Z', + updated_at: row.updated_at ?? '2026-06-29T00:00:00.000Z', + ...row, + }; + this.tables[table] = [...(this.tables[table] ?? []), inserted]; + return { ...inserted }; + } + + update(table: string, filters: Array<{ key: string; op: string; value: any }>, payload: Row) { + this.tables[table] = (this.tables[table] ?? []).map((row) => + this.matches(row, filters) ? { ...row, ...payload } : row, + ); + } + + delete(table: string, filters: Array<{ key: string; op: string; value: any }>) { + this.tables[table] = (this.tables[table] ?? []).filter((row) => !this.matches(row, filters)); + } + + select(table: string, filters: Array<{ key: string; op: string; value: any }>, columns?: string) { + return (this.tables[table] ?? []) + .filter((row) => this.matches(row, filters)) + .map((row) => this.project(table, row, columns)); + } + + private matches(row: Row, filters: Array<{ key: string; op: string; value: any }>) { + return filters.every((filter) => { + if (filter.op === 'eq') return row[filter.key] === filter.value; + if (filter.op === 'neq') return row[filter.key] !== filter.value; + if (filter.op === 'in') return filter.value.includes(row[filter.key]); + return true; + }); + } + + private project(_table: string, row: Row, _columns?: string) { + return { ...row }; + } +} + +class QueryBuilder implements PromiseLike { + private selected: string | undefined; + private filters: Array<{ key: string; op: 'eq' | 'neq' | 'in'; value: any }> = []; + private orderBy: { key: string; ascending: boolean } | undefined; + private rowLimit: number | undefined; + private mode: 'select' | 'insert' | 'update' | 'delete' = 'select'; + private payload: any; + private resultMode: 'many' | 'single' | 'maybeSingle' = 'many'; + private countRequested = false; + + constructor( + private readonly db: InMemorySupabase, + private readonly table: string, + ) {} + + select(columns = '*', options?: { count?: 'exact'; head?: boolean }) { + this.selected = columns; + this.countRequested = options?.count === 'exact'; + return this; + } + + eq(key: string, value: any) { + this.filters.push({ key, op: 'eq', value }); + return this; + } + + neq(key: string, value: any) { + this.filters.push({ key, op: 'neq', value }); + return this; + } + + in(key: string, value: any[]) { + this.filters.push({ key, op: 'in', value }); + return this; + } + + order(key: string, options?: { ascending?: boolean }) { + this.orderBy = { key, ascending: options?.ascending ?? true }; + return this; + } + + limit(count: number) { + this.rowLimit = count; + return this; + } + + maybeSingle() { + this.resultMode = 'maybeSingle'; + return this; + } + + single() { + this.resultMode = 'single'; + return this; + } + + insert(payload: any) { + this.mode = 'insert'; + this.payload = payload; + return this; + } + + update(payload: any) { + this.mode = 'update'; + this.payload = payload; + return this; + } + + delete() { + this.mode = 'delete'; + return this; + } + + then( + onfulfilled?: ((value: QueryResult) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike { + return Promise.resolve(this.execute()).then(onfulfilled, onrejected); + } + + private execute(): QueryResult { + if (this.mode === 'insert') return this.executeInsert(); + if (this.mode === 'update') return this.executeUpdate(); + if (this.mode === 'delete') return this.executeDelete(); + return this.executeSelect(); + } + + private executeInsert(): QueryResult { + const rows = Array.isArray(this.payload) ? this.payload : [this.payload]; + const inserted = rows.map((row) => this.db.insert(this.table, row)); + const data = this.resultMode === 'many' && !this.selected ? null : inserted[0]; + return { data, error: null }; + } + + private executeUpdate(): QueryResult { + this.db.update(this.table, this.filters, this.payload); + return { data: null, error: null }; + } + + private executeDelete(): QueryResult { + this.db.delete(this.table, this.filters); + return { data: null, error: null }; + } + + private executeSelect(): QueryResult { + let rows = this.db.select(this.table, this.filters, this.selected); + if (this.orderBy) { + rows = rows.sort((a, b) => { + const av = a[this.orderBy!.key]; + const bv = b[this.orderBy!.key]; + if (av === bv) return 0; + const result = av > bv ? 1 : -1; + return this.orderBy!.ascending ? result : -result; + }); + } + if (this.rowLimit !== undefined) rows = rows.slice(0, this.rowLimit); + + if (this.countRequested) { + return { data: null, count: rows.length, error: null }; + } + if (this.resultMode === 'maybeSingle') { + return { data: rows[0] ?? null, error: null }; + } + if (this.resultMode === 'single') { + return rows[0] + ? { data: rows[0], error: null } + : { data: null, error: { message: 'No rows found', code: 'PGRST116' } }; + } + return { data: rows, error: null }; + } +} + +class StubProvider implements IVerificationProvider { + readonly name = VerificationProvider.MOCK; + readonly supportedTypes: VerificationType[] = [VerificationType.KYC, VerificationType.KYB]; + + private shouldFail = false; + private failOn: 'create' | 'getStatus' | 'handleWebhook' = 'create'; + private delayMs = 0; + private config: ProviderConfig = {}; + + // Response templates that can be customized + private createSessionResponse: { + provider_session_id: string; + provider_url?: string; + expires_at: string; + } = { + provider_session_id: `mock-session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + provider_url: `https://mock-verification.test/session/${Math.random().toString(36).slice(2, 9)}`, + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 hours from now + }; + + private getStatusResponse: { + status: VerificationStatus; + result?: Record; + error?: { code: string; message: string }; + } = { + status: VerificationStatus.COMPLETED, + }; + + private handleWebhookResponse: { + status: VerificationStatus; + } = { + status: VerificationStatus.COMPLETED, + }; + + configure(config: { + shouldFail?: boolean; + failOn?: 'create' | 'getStatus' | 'handleWebhook'; + createSessionResponse?: { + provider_session_id: string; + provider_url?: string; + expires_at: string; + }; + getStatusResponse?: { + status: VerificationStatus; + result?: Record; + error?: { code: string; message: string }; + }; + handleWebhookResponse?: { + status: VerificationStatus; + }; + }): this { + if (config.shouldFail !== undefined) { + this.shouldFail = config.shouldFail; + } + if (config.failOn) { + this.failOn = config.failOn; + } + if (config.createSessionResponse) { + this.createSessionResponse = config.createSessionResponse; + } + if (config.getStatusResponse) { + this.getStatusResponse = { + ...this.getStatusResponse, + ...(config.getStatusResponse as any), + }; + } + if (config.handleWebhookResponse) { + this.handleWebhookResponse = { + ...this.handleWebhookResponse, + ...(config.handleWebhookResponse as any), + }; + } + return this; + } + + async createSession( + _subject: any, + _type: VerificationType, + ): Promise<{ + provider_session_id: string; + provider_url?: string; + expires_at: string; + }> { + if (this.delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, this.delayMs)); + } + + if (this.shouldFail && this.failOn === 'create') { + throw new Error('MOCK_PROVIDER_ERROR: Failed to create verification session'); + } + + // Return a copy of the template with a fresh session ID + return { + ...this.createSessionResponse, + }; + } + + async getStatus(_sessionId: string): Promise<{ + status: VerificationStatus; + result?: Record; + error?: { code: string; message: string }; + }> { + if (this.delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, this.delayMs)); + } + + if (this.shouldFail && this.failOn === 'getStatus') { + throw new Error('MOCK_PROVIDER_ERROR: Failed to get verification status'); + } + + return { ...this.getStatusResponse }; + } + + async handleWebhook(payload: any): Promise<{ + status: VerificationStatus; + result?: Record; + error?: { code: string; message: string }; + }> { + if (this.delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, this.delayMs)); + } + + if (this.shouldFail && this.failOn === 'handleWebhook') { + throw new Error('MOCK_PROVIDER_ERROR: Webhook processing failed'); + } + + // Update the response based on the webhook payload + const statusMap: Record = { + 'verification.completed': VerificationStatus.COMPLETED, + 'verification.failed': VerificationStatus.FAILED, + 'verification.processing': VerificationStatus.PROCESSING, + 'verification.expired': VerificationStatus.EXPIRED, + 'session.created': VerificationStatus.PENDING, + }; + + const mappedStatus = statusMap[payload.event] ?? VerificationStatus.PENDING; + + return { + status: mappedStatus, + result: payload.result, + error: payload.error, + }; + } + + validateWebhookSignature(payload: string, signature: string): boolean { + if (!this.config.webhookSecret) return true; + const expected = `sha256=${Buffer.from(payload + this.config.webhookSecret) + .toString('base64') + .slice(0, 43)}`; + return signature === expected; + } +} + +describe('Verification KYC/KYB Integration Test Suite', () => { + let app: INestApplication | null = null; + let supabase: InMemorySupabase; + let stubProvider: StubProvider; + + beforeAll(async () => { + process.env.JWT_SECRET = JWT_SECRET; + process.env.TRUSTLESSWORK_API_URL = 'https://trustless-work.test'; + process.env.TRUSTLESSWORK_API_KEY = 'tw-test-key'; + + try { + supabase = new InMemorySupabase(); + stubProvider = new StubProvider(); + + // Create mock wallet service + const mockWalletsService = { + getPrimaryWallet: jest.fn().mockResolvedValue({ + wallet_address: WALLET, + user_id: USER_ID, + wallet_type: 'custodial', + label: 'Primary Wallet', + is_primary: true, + is_verified: true, + verified_at: new Date().toISOString(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }), + linkWallet: jest.fn().mockResolvedValue({ + wallet: { + wallet_address: WALLET, + user_id: USER_ID, + wallet_type: 'custodial', + label: 'Primary Wallet', + is_primary: true, + is_verified: true, + verified_at: new Date().toISOString(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + error: null, + }), + }; + + const moduleRef = await Test.createTestingModule({ + imports: [AuthModule], + controllers: [VerificationController], + providers: [ + VerificationService, + VerificationProviderFactory, + { provide: SupabaseService, useValue: supabase }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + switch (key) { + case 'JWT_SECRET': + return JWT_SECRET; + case 'SUPABASE_URL': + return process.env.SUPABASE_URL; + case 'SUPABASE_SERVICE_ROLE_KEY': + return process.env.SUPABASE_SERVICE_ROLE_KEY; + case 'STELLAR_NETWORK': + return 'testnet'; + default: + return null; + } + }), + }, + }, + { provide: WalletsService, useValue: mockWalletsService }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + app.setGlobalPrefix('v1'); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + await app.init(); + + const providerFactory = app.get(VerificationProviderFactory); + providerFactory.registerProvider(stubProvider); + } catch (error) { + console.error('Failed to initialize test module:', error); + throw error; + } + }); + + beforeEach(() => { + if (supabase) { + supabase.reset(); + } + stubProvider = new StubProvider(); // Reset to default state + if (app) { + const providerFactory = app.get(VerificationProviderFactory); + providerFactory.registerProvider(stubProvider); + } + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + afterAll(async () => { + if (app) { + await app.close(); + } + }); + + const tokenFor = (sub = USER_ID) => + jwt.sign({ sub, email: `${sub}@example.com` }, JWT_SECRET, { + algorithm: 'HS256', + expiresIn: '7d', + }); + + const auth = (sub = USER_ID) => ({ Authorization: `Bearer ${tokenFor(sub)}` }); + + describe('KYC/KYB Verification Integration', () => { + describe('Verification Session Creation', () => { + it('creates a KYC verification session with mock provider', async () => { + if (!app) throw new Error('App not initialized'); + + stubProvider.configure({ + createSessionResponse: { + provider_session_id: 'mock-session-kyc-123', + provider_url: 'https://mock.test/kyc/123', + expires_at: new Date(Date.now() + 86400000).toISOString(), + }, + }); + + // Try with minimal subject first + const response = await request(app.getHttpServer()) + .post('/v1/verification/sessions') + .set(auth()) + .send({ + type: 'kyc', + subject: { + type: 'kyc', + }, + provider: 'mock', + }); + + // Debug: Print response if it fails + console.log('Response status:', response.status); + console.log('Response body:', JSON.stringify(response.body, null, 2)); + + expect(response.status).toBe(201); + expect(response.body.error).toBeNull(); + expect(response.body.session).toBeDefined(); + expect(response.body.session.type).toBe('kyc'); + expect(response.body.session.provider).toBe('mock'); + expect(response.body.session.status).toBe('pending'); + expect(response.body.session.provider_session_id).toBe('mock-session-kyc-123'); + expect(response.body.session.provider_url).toBe('https://mock.test/kyc/123'); + }); + }); + }); +}); diff --git a/src/verification/dto/verification.dto.ts b/src/verification/dto/verification.dto.ts new file mode 100644 index 0000000..694e085 --- /dev/null +++ b/src/verification/dto/verification.dto.ts @@ -0,0 +1,137 @@ +import { IsEnum, IsOptional, IsString, IsBoolean, IsObject } from 'class-validator'; + +export enum VerificationType { + KYC = 'kyc', + KYB = 'kyb', +} + +export enum VerificationStatus { + PENDING = 'pending', + PROCESSING = 'processing', + COMPLETED = 'completed', + FAILED = 'failed', + EXPIRED = 'expired', +} + +export enum VerificationProvider { + MOCK = 'mock', + ONFIDO = 'onfido', + SUMSUB = 'sumsub', + VERIFF = 'veriff', + PERSONA = 'persona', + TRULIOO = 'trulioo', +} + +export interface VerificationSubject { + id?: string; + type: VerificationType; + first_name?: string; + last_name?: string; + email?: string; + phone_number?: string; + date_of_birth?: string; + address?: { + street?: string; + city?: string; + state?: string; + postal_code?: string; + country?: string; + }; + company_name?: string; + registration_number?: string; + jurisdiction?: string; + directors?: Array<{ + first_name: string; + last_name: string; + date_of_birth: string; + }>; + documents?: Array<{ + type: string; + file_url?: string; + file_name?: string; + }>; +} + +export interface VerificationSession { + id: string; + provider: VerificationProvider; + type: VerificationType; + subject: VerificationSubject; + status: VerificationStatus; + provider_session_id?: string; + provider_url?: string; + result?: { + score?: number; + risk_level?: 'low' | 'medium' | 'high'; + breakdown?: Record; + raw_response?: Record; + }; + error?: { + code: string; + message: string; + details?: Record; + }; + created_at: string; + updated_at: string; + expires_at?: string; + completed_at?: string; +} + +export interface ProviderCreateSessionResponse { + provider_session_id: string; + provider_url?: string; + expires_at: string; +} + +export interface ProviderStatusResponse { + status: VerificationStatus; + result?: VerificationSession['result']; + error?: VerificationSession['error']; +} + +export interface ProviderWebhookPayload { + session_id: string; + event: string; + status: VerificationStatus; + result?: Record; + error?: Record; + timestamp: string; +} + +export class CreateVerificationSessionDto { + @IsEnum(VerificationType) + type: VerificationType; + + @IsObject() + subject: VerificationSubject; + + @IsOptional() + @IsEnum(VerificationProvider) + provider?: VerificationProvider; +} + +export class VerificationCallbackDto { + @IsString() + session_id: string; + + @IsEnum(VerificationStatus) + status: VerificationStatus; + + @IsOptional() + result?: Record; + + @IsOptional() + error?: Record; +} + +export class WebhookVerificationDto { + @IsString() + provider: string; + + @IsString() + signature?: string; + + @IsOptional() + @IsBoolean() + skip_validation?: boolean; +} diff --git a/src/verification/providers/mock.provider.ts b/src/verification/providers/mock.provider.ts new file mode 100644 index 0000000..bbc71dd --- /dev/null +++ b/src/verification/providers/mock.provider.ts @@ -0,0 +1,125 @@ +import { IVerificationProvider, ProviderConfig } from './verification.provider'; +import { + VerificationStatus, + VerificationType, + VerificationProvider, + ProviderCreateSessionResponse, + ProviderStatusResponse, + ProviderWebhookPayload, + VerificationSubject, +} from '../dto/verification.dto'; + +export class MockVerificationProvider implements IVerificationProvider { + readonly name = VerificationProvider.MOCK; + readonly supportedTypes: VerificationType[] = [VerificationType.KYC, VerificationType.KYB]; + + private shouldFail = false; + private failOn: 'create' | 'getStatus' | 'handleWebhook' = 'create'; + private delayMs = 0; + private config: ProviderConfig = {}; + + // Response templates that can be customized + private createSessionResponse: ProviderCreateSessionResponse = { + provider_session_id: `mock-session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + provider_url: `https://mock-verification.test/session/${Math.random().toString(36).slice(2, 9)}`, + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 hours from now + }; + + private getStatusResponse: ProviderStatusResponse = { + status: VerificationStatus.COMPLETED, + }; + + private handleWebhookResponse: ProviderStatusResponse = { + status: VerificationStatus.COMPLETED, + }; + + configure(config: { + shouldFail?: boolean; + failOn?: 'create' | 'getStatus' | 'handleWebhook'; + createSessionResponse?: ProviderCreateSessionResponse; + getStatusResponse?: ProviderStatusResponse; + handleWebhookResponse?: ProviderStatusResponse; + }): this { + if (config.shouldFail !== undefined) { + this.shouldFail = config.shouldFail; + } + if (config.failOn) { + this.failOn = config.failOn; + } + if (config.createSessionResponse) { + this.createSessionResponse = config.createSessionResponse; + } + if (config.getStatusResponse) { + this.getStatusResponse = config.getStatusResponse; + } + if (config.handleWebhookResponse) { + this.handleWebhookResponse = config.handleWebhookResponse; + } + return this; + } + + async createSession( + _subject: VerificationSubject, + _type: VerificationType, + ): Promise { + if (this.delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, this.delayMs)); + } + + if (this.shouldFail && this.failOn === 'create') { + throw new Error('MOCK_PROVIDER_ERROR: Failed to create verification session'); + } + + // Return a copy of the template with a fresh session ID + return { + ...this.createSessionResponse, + }; + } + + async getStatus(_sessionId: string): Promise { + if (this.delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, this.delayMs)); + } + + if (this.shouldFail && this.failOn === 'getStatus') { + throw new Error('MOCK_PROVIDER_ERROR: Failed to get verification status'); + } + + return { ...this.getStatusResponse }; + } + + async handleWebhook(payload: ProviderWebhookPayload): Promise { + if (this.delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, this.delayMs)); + } + + if (this.shouldFail && this.failOn === 'handleWebhook') { + throw new Error('MOCK_PROVIDER_ERROR: Webhook processing failed'); + } + + // Update the response based on the webhook payload + const statusMap: Record = { + 'verification.completed': VerificationStatus.COMPLETED, + 'verification.failed': VerificationStatus.FAILED, + 'verification.processing': VerificationStatus.PROCESSING, + 'verification.expired': VerificationStatus.EXPIRED, + 'session.created': VerificationStatus.PENDING, + }; + + const mappedStatus = statusMap[payload.event] ?? VerificationStatus.PENDING; + + return { + status: mappedStatus, + result: payload.result ?? undefined, + error: payload.error ?? undefined, + } as ProviderStatusResponse; + } + + validateWebhookSignature(payload: string, signature: string): boolean { + if (!this.config.webhookSecret) return true; + const expected = `sha256=${Buffer.from(payload + this.config.webhookSecret) + .toString('base64') + .slice(0, 43)}`; + return signature === expected; + } +} diff --git a/src/verification/providers/provider-factory.ts b/src/verification/providers/provider-factory.ts new file mode 100644 index 0000000..75fc2ec --- /dev/null +++ b/src/verification/providers/provider-factory.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { IVerificationProvider, ProviderConfig } from './verification.provider'; +import { VerificationProvider } from '../dto/verification.dto'; +import { MockVerificationProvider } from './mock.provider'; + +export interface ProviderFactoryOptions { + provider?: VerificationProvider; + config?: ProviderConfig; +} + +@Injectable() +export class VerificationProviderFactory { + private providers: Map = new Map(); + + constructor(private configService: ConfigService) { + this.registerDefaults(); + } + + private registerDefaults() { + this.providers.set(VerificationProvider.MOCK, new MockVerificationProvider()); + } + + registerProvider(provider: IVerificationProvider) { + this.providers.set(provider.name, provider); + } + + getProvider(options: ProviderFactoryOptions = {}): IVerificationProvider { + const providerName = options.provider || VerificationProvider.MOCK; + const provider = this.providers.get(providerName); + + if (!provider) { + throw new Error(`Unsupported verification provider: ${providerName}`); + } + + const _config: ProviderConfig = { + apiKey: + options.config?.apiKey || + this.configService.get(`${providerName.toUpperCase()}_API_KEY`), + apiSecret: + options.config?.apiSecret || + this.configService.get(`${providerName.toUpperCase()}_API_SECRET`), + baseUrl: + options.config?.baseUrl || + this.configService.get(`${providerName.toUpperCase()}_BASE_URL`), + webhookSecret: + options.config?.webhookSecret || + this.configService.get(`${providerName.toUpperCase()}_WEBHOOK_SECRET`), + timeoutMs: options.config?.timeoutMs || 30000, + }; + + if (provider instanceof MockVerificationProvider) { + // Configure the existing mock provider instance instead of creating a new one + provider.configure({ + // Note: We're not using the config directly in the mock provider for simplicity + // In a real implementation, you might want to use these values + // For now, we just return the configured instance + }); + return provider; + } + + return provider; + } + + getSupportedProviders(): VerificationProvider[] { + return Array.from(this.providers.keys()); + } +} diff --git a/src/verification/providers/verification.provider.ts b/src/verification/providers/verification.provider.ts new file mode 100644 index 0000000..dfeb7a2 --- /dev/null +++ b/src/verification/providers/verification.provider.ts @@ -0,0 +1,32 @@ +import { + VerificationProvider, + VerificationType, + VerificationSubject, + ProviderCreateSessionResponse, + ProviderStatusResponse, + ProviderWebhookPayload, +} from '../dto/verification.dto'; + +export interface IVerificationProvider { + readonly name: VerificationProvider; + readonly supportedTypes: VerificationType[]; + + createSession( + subject: VerificationSubject, + type: VerificationType, + ): Promise; + + getStatus(sessionId: string): Promise; + + handleWebhook(payload: ProviderWebhookPayload): Promise; + + validateWebhookSignature(payload: string, signature: string): boolean; +} + +export interface ProviderConfig { + apiKey?: string; + apiSecret?: string; + baseUrl?: string; + webhookSecret?: string; + timeoutMs?: number; +} diff --git a/src/verification/verification.controller.ts b/src/verification/verification.controller.ts new file mode 100644 index 0000000..30e8d6f --- /dev/null +++ b/src/verification/verification.controller.ts @@ -0,0 +1,69 @@ +import { Body, Controller, Get, Param, Post, UseGuards, BadRequestException } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser, type AuthUserCtx } from '../auth/current-user.decorator'; +import { VerificationService } from './verification.service'; +import { WalletsService } from '../wallets/wallets.service'; +import { CreateVerificationSessionDto, VerificationType } from './dto/verification.dto'; + +@ApiTags('verification') +@ApiBearerAuth('bearer') +@Controller('verification') +export class VerificationController { + constructor( + private readonly verificationService: VerificationService, + private readonly walletsService: WalletsService, + ) {} + + @Post('sessions') + @UseGuards(JwtAuthGuard) + async createSession(@CurrentUser() user: AuthUserCtx, @Body() dto: CreateVerificationSessionDto) { + // Get the user's primary wallet address + const primaryWallet = await this.walletsService.getPrimaryWallet(user.userId); + if (!primaryWallet) { + throw new BadRequestException('No wallet found for user'); + } + + const result = await this.verificationService.createSession( + user.userId, + primaryWallet.wallet_address, + dto, + ); + + if (result.error) { + throw new BadRequestException(result.error); + } + + return { session: result.session, error: null }; + } + + @Get('sessions') + @UseGuards(JwtAuthGuard) + async getSessions( + @CurrentUser() user: AuthUserCtx, + @Param() _params: { type?: VerificationType }, + ) { + const type = _params?.type; + const result = await this.verificationService.getSessionsByUser(user.userId, type); + + return { sessions: result.sessions, error: result.error }; + } + + @Get('sessions/:id') + @UseGuards(JwtAuthGuard) + async getSession(@CurrentUser() user: AuthUserCtx, @Param('id') sessionId: string) { + const result = await this.verificationService.getSession(user.userId, sessionId); + + if (result.error) { + throw new BadRequestException(result.error); + } + + return { session: result.session, error: null }; + } + + @Get('providers') + getProviders() { + const providers = this.verificationService.getSupportedProviders(); + return { providers, error: null }; + } +} diff --git a/src/verification/verification.module.ts b/src/verification/verification.module.ts new file mode 100644 index 0000000..109e6c2 --- /dev/null +++ b/src/verification/verification.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { SupabaseModule } from '../supabase/supabase.module'; +import { WalletsModule } from '../wallets/wallets.module'; +import { VerificationController } from './verification.controller'; +import { VerificationService } from './verification.service'; +import { VerificationProviderFactory } from './providers/provider-factory'; + +@Module({ + imports: [ConfigModule, SupabaseModule, WalletsModule], + controllers: [VerificationController], + providers: [VerificationService, VerificationProviderFactory], + exports: [VerificationService, VerificationProviderFactory], +}) +export class VerificationModule {} diff --git a/src/verification/verification.service.ts b/src/verification/verification.service.ts new file mode 100644 index 0000000..590236a --- /dev/null +++ b/src/verification/verification.service.ts @@ -0,0 +1,269 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SupabaseService } from '../supabase/supabase.service'; +import { VerificationProviderFactory } from '../verification/providers/provider-factory'; +import { + CreateVerificationSessionDto, + ProviderWebhookPayload, + VerificationProvider, + VerificationSession, + VerificationStatus, + VerificationType, +} from '../verification/dto/verification.dto'; + +@Injectable() +export class VerificationService { + constructor( + private readonly supabase: SupabaseService, + private readonly configService: ConfigService, + private readonly providerFactory: VerificationProviderFactory, + ) {} + + async createSession( + userId: string, + walletAddress: string, + dto: CreateVerificationSessionDto, + ): Promise<{ session: VerificationSession | null; error: string | null }> { + if (!dto.type) { + throw new BadRequestException('Verification type (kyc or kyb) is required'); + } + + const provider = dto.provider ?? VerificationProvider.MOCK; + const providerInstance = this.providerFactory.getProvider({ provider }); + + if (!providerInstance.supportedTypes.includes(dto.type)) { + throw new BadRequestException( + `Provider ${provider} does not support ${dto.type} verifications`, + ); + } + + try { + const providerResponse = await providerInstance.createSession(dto.subject, dto.type); + + const expiresAt = new Date(providerResponse.expires_at); + const now = new Date().toISOString(); + + const { data: session, error: insertError } = await this.supabase + .getClient() + .from('verification_sessions') + .insert({ + user_id: userId, + wallet_address: walletAddress, + type: dto.type, + provider, + status: VerificationStatus.PENDING, + provider_session_id: providerResponse.provider_session_id, + provider_url: providerResponse.provider_url, + subject: dto.subject, + expires_at: expiresAt.toISOString(), + created_at: now, + updated_at: now, + }) + .select() + .single(); + + if (insertError) { + return { + session: null, + error: `Failed to create verification session: ${insertError.message}`, + }; + } + + return { session: session as VerificationSession, error: null }; + } catch (err) { + return { + session: null, + error: err instanceof Error ? err.message : 'Unknown provider error', + }; + } + } + + async getSession( + userId: string, + sessionId: string, + ): Promise<{ session: VerificationSession | null; error: string | null }> { + const { data: session, error: fetchError } = await this.supabase + .getClient() + .from('verification_sessions') + .select('*') + .eq('id', sessionId) + .eq('user_id', userId) + .maybeSingle(); + + if (fetchError || !session) { + return { session: null, error: 'Verification session not found' }; + } + + const existingSession = session as VerificationSession; + + // Handle expired sessions + if (existingSession.expires_at && new Date(existingSession.expires_at) < new Date()) { + if (existingSession.status === VerificationStatus.PENDING) { + await this.supabase + .getClient() + .from('verification_sessions') + .update({ status: VerificationStatus.EXPIRED, updated_at: new Date().toISOString() }) + .eq('id', sessionId); + + existingSession.status = VerificationStatus.EXPIRED; + } + } + + // Check status from provider if still pending + if (existingSession.status === VerificationStatus.PENDING) { + // Ensure we have a provider session ID before checking status + if (!existingSession.provider_session_id) { + // This shouldn't happen for valid sessions, but handle gracefully + await this.supabase + .getClient() + .from('verification_sessions') + .update({ + status: VerificationStatus.FAILED, + error: { code: 'MISSING_PROVIDER_SESSION', message: 'Provider session ID not found' }, + updated_at: new Date().toISOString(), + }) + .eq('id', sessionId); + + existingSession.status = VerificationStatus.FAILED; + existingSession.error = { + code: 'MISSING_PROVIDER_SESSION', + message: 'Provider session ID not found', + }; + } else { + const provider = this.providerFactory.getProvider({ + provider: existingSession.provider, + }); + + try { + const providerStatus = await provider.getStatus(existingSession.provider_session_id); + const updates: Record = { + status: providerStatus.status, + updated_at: new Date().toISOString(), + }; + + if (providerStatus.result) { + updates.result = providerStatus.result; + } + + if (providerStatus.error) { + updates.error = providerStatus.error; + } + + if (providerStatus.status === VerificationStatus.COMPLETED) { + updates.completed_at = new Date().toISOString(); + } + + await this.supabase + .getClient() + .from('verification_sessions') + .update(updates) + .eq('id', sessionId); + + Object.assign(existingSession, updates); + } catch (err) { + await this.supabase + .getClient() + .from('verification_sessions') + .update({ + status: VerificationStatus.FAILED, + error: { + code: 'PROVIDER_ERROR', + message: err instanceof Error ? err.message : 'Unknown error', + }, + updated_at: new Date().toISOString(), + }) + .eq('id', sessionId); + + existingSession.status = VerificationStatus.FAILED; + existingSession.error = { + code: 'PROVIDER_ERROR', + message: err instanceof Error ? err.message : 'Unknown error', + }; + } + } + } + + return { session: existingSession, error: null }; + } + + async getSessionsByUser( + userId: string, + type?: VerificationType, + ): Promise<{ sessions: VerificationSession[]; error: string | null }> { + let query = this.supabase + .getClient() + .from('verification_sessions') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }); + + if (type) { + query = query.eq('type', type); + } + + const { data: sessions, error: fetchError } = await query; + + if (fetchError) { + return { sessions: [], error: fetchError.message }; + } + + return { sessions: (sessions as VerificationSession[]) || [], error: null }; + } + + async handleWebhook( + provider: VerificationProvider, + payload: ProviderWebhookPayload, + ): Promise<{ handled: boolean; error: string | null }> { + const providerInstance = this.providerFactory.getProvider({ provider }); + + try { + const providerStatus = await providerInstance.handleWebhook(payload); + + const { data: session, error: fetchError } = await this.supabase + .getClient() + .from('verification_sessions') + .select('id, status') + .eq('provider_session_id', payload.session_id) + .eq('provider', provider) + .maybeSingle(); + + if (fetchError || !session) { + return { handled: false, error: 'Session not found for provider session' }; + } + + const updates: Record = { + status: providerStatus.status, + updated_at: new Date().toISOString(), + }; + + if (providerStatus.result) { + updates.result = providerStatus.result; + } + + if (providerStatus.error) { + updates.error = providerStatus.error; + } + + if (providerStatus.status === VerificationStatus.COMPLETED) { + updates.completed_at = new Date().toISOString(); + } + + await this.supabase + .getClient() + .from('verification_sessions') + .update(updates) + .eq('id', (session as { id: string }).id); + + return { handled: true, error: null }; + } catch (err) { + return { + handled: false, + error: err instanceof Error ? err.message : 'Webhook handling failed', + }; + } + } + + getSupportedProviders(): VerificationProvider[] { + return this.providerFactory.getSupportedProviders(); + } +} diff --git a/src/wallets/wallets.module.ts b/src/wallets/wallets.module.ts index 14d17f9..89bb65b 100644 --- a/src/wallets/wallets.module.ts +++ b/src/wallets/wallets.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { WalletsController } from './wallets.controller'; import { WalletsService } from './wallets.service'; import { AuthModule } from '../auth/auth.module'; import { CommonModule } from '../common/common.module'; @Module({ - imports: [AuthModule, CommonModule], + imports: [AuthModule, CommonModule, ConfigModule], controllers: [WalletsController], providers: [WalletsService], exports: [WalletsService], diff --git a/tsconfig.json b/tsconfig.json index a3fd2c5..14e6c64 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "module": "commonjs", + "types": ["node", "jest"], "declaration": true, "removeComments": true, "emitDecoratorMetadata": true,