diff --git a/.env.example b/.env.example index b6eff06..9b4d3e8 100644 --- a/.env.example +++ b/.env.example @@ -57,22 +57,46 @@ PORT="3001" # Defaults to allowing all origins when unset. THALOS_CORS_ORIGIN="http://localhost:3000" -# Public base URL of the frontend app, used to build links in outgoing emails -# (e.g. contact invites). Falls back to the first THALOS_CORS_ORIGIN, then -# http://localhost:3000 when unset. -THALOS_APP_PUBLIC_URL="http://localhost:3000" - -# ---- Stellar / Trustless Work defaults (optional) ----------- -# All have built-in testnet defaults; override for mainnet / a different setup. - -# Stellar network the wallets/escrows run on: "testnet" (default) or "mainnet". -STELLAR_NETWORK="testnet" - -# Platform (fee/owner) Stellar address used when creating escrows. -PLATFORM_ADDRESS="GBTTKTSBLHGMRY3T65JXT423MHQZXTD26TTHQEY5HNF2KWFFDKKVHVPD" - -# Default dispute resolver Stellar address for new escrows. -DISPUTE_RESOLVER="GB6MP3L6UGIDY6O6MXNLSKHLXT2T2TCMPZIZGUTOGYKOLHW7EORWMFCK" - -# USDC trustline (asset issuer) address used for escrow funding. -TRUSTLINE_USDC_ADDRESS="GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" +# ---- Identity Verification (KYC/KYB) ------------------------ +# Provider-agnostic identity verification layer. The active provider is +# selected here; per-provider credentials follow the convention +# _API_KEY / _API_SECRET / _BASE_URL / +# _WEBHOOK_SECRET so new vendors can be added without code changes. + +# Default provider used when a request does not specify one. +# One of: mock | sumsub | persona | veriff | synaps | stripe_identity | alloy +# Defaults to "mock" (safe for local/dev/test) when unset. +VERIFICATION_PROVIDER="mock" + +# --- Sumsub --- +# SUMSUB_API_KEY="" +# SUMSUB_API_SECRET="" +# SUMSUB_BASE_URL="https://api.sumsub.com" +# SUMSUB_WEBHOOK_SECRET="" + +# --- Persona --- +# PERSONA_API_KEY="" +# PERSONA_BASE_URL="https://withpersona.com/api/v1" +# PERSONA_WEBHOOK_SECRET="" + +# --- Veriff --- +# VERIFF_API_KEY="" +# VERIFF_API_SECRET="" +# VERIFF_BASE_URL="https://stationapi.veriff.com" +# VERIFF_WEBHOOK_SECRET="" + +# --- Synaps --- +# SYNAPS_API_KEY="" +# SYNAPS_BASE_URL="https://api.synaps.io" +# SYNAPS_WEBHOOK_SECRET="" + +# --- Stripe Identity --- +# STRIPE_IDENTITY_API_KEY="" +# STRIPE_IDENTITY_BASE_URL="https://api.stripe.com" +# STRIPE_IDENTITY_WEBHOOK_SECRET="" + +# --- Alloy --- +# ALLOY_API_KEY="" +# ALLOY_API_SECRET="" +# ALLOY_BASE_URL="https://sandbox.alloy.co" +# ALLOY_WEBHOOK_SECRET="" 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..9a71041 --- /dev/null +++ b/src/verification/dto/verification.dto.ts @@ -0,0 +1,160 @@ +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', + CANCELLED = 'cancelled', +} + +export enum VerificationProvider { + MOCK = 'mock', + SUMSUB = 'sumsub', + PERSONA = 'persona', + VERIFF = 'veriff', + SYNAPS = 'synaps', + STRIPE_IDENTITY = 'stripe_identity', + ALLOY = 'alloy', +} + +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; + cancelled_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']; +} + +/** + * Normalized verification result returned by a provider. + * Providers translate their vendor-specific payloads into this shape so that + * the core services never depend on a specific vendor's response format. + */ +export interface ProviderVerificationResult { + status: VerificationStatus; + result?: VerificationSession['result']; + error?: VerificationSession['error']; + completed_at?: string; +} + +/** + * Outcome of a cancellation request against a provider session. + */ +export interface ProviderCancelResponse { + cancelled: boolean; + status: VerificationStatus; +} + +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..31ef2dd --- /dev/null +++ b/src/verification/providers/mock.provider.ts @@ -0,0 +1,161 @@ +import { IVerificationProvider, ProviderConfig } from './verification.provider'; +import { + VerificationStatus, + VerificationType, + VerificationProvider, + ProviderCreateSessionResponse, + ProviderStatusResponse, + ProviderVerificationResult, + ProviderCancelResponse, + 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 getResultsResponse: ProviderVerificationResult = { + status: VerificationStatus.COMPLETED, + result: { + score: 0.99, + risk_level: 'low', + breakdown: { document: 'clear', facial_similarity: 'clear' }, + }, + completed_at: new Date().toISOString(), + }; + + private handleWebhookResponse: ProviderStatusResponse = { + status: VerificationStatus.COMPLETED, + }; + + applyConfig(config: ProviderConfig): void { + this.config = { ...this.config, ...config }; + } + + configure(config: { + shouldFail?: boolean; + failOn?: 'create' | 'getStatus' | 'handleWebhook'; + createSessionResponse?: ProviderCreateSessionResponse; + getStatusResponse?: ProviderStatusResponse; + getResultsResponse?: ProviderVerificationResult; + 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.getResultsResponse) { + this.getResultsResponse = config.getResultsResponse; + } + 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 getResults(_sessionId: string): Promise { + if (this.delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, this.delayMs)); + } + + return { ...this.getResultsResponse }; + } + + async cancelSession(_sessionId: string): Promise { + if (this.delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, this.delayMs)); + } + + return { cancelled: true, status: VerificationStatus.CANCELLED }; + } + + 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..1b58bda --- /dev/null +++ b/src/verification/providers/provider-factory.ts @@ -0,0 +1,119 @@ +import { Injectable, Logger } 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; +} + +/** + * Central registry + resolver for identity verification providers. + * + * - Providers register themselves here (defaults are wired in the constructor; + * additional providers can be added at runtime via {@link registerProvider}). + * - The active/default provider is configurable through the + * `VERIFICATION_PROVIDER` environment variable (or application config), + * allowing operators to switch vendors without touching core code. + * - Per-provider credentials are resolved from env using the convention + * `_API_KEY`, `_API_SECRET`, `_BASE_URL`, + * `_WEBHOOK_SECRET` (e.g. `SUMSUB_API_KEY`, `PERSONA_API_KEY`). + */ +@Injectable() +export class VerificationProviderFactory { + private readonly logger = new Logger(VerificationProviderFactory.name); + private readonly providers = new Map(); + + constructor(private readonly configService: ConfigService) { + this.registerDefaults(); + } + + private registerDefaults(): void { + // The mock provider is always available so the abstraction is usable in + // local/dev/test environments without any external credentials. + this.registerProvider(new MockVerificationProvider()); + } + + /** + * Register (or replace) a provider implementation. Concrete vendor providers + * (Sumsub, Persona, Veriff, Synaps, Stripe Identity, Alloy, ...) plug in here. + */ + registerProvider(provider: IVerificationProvider): void { + this.providers.set(provider.name, provider); + } + + /** + * Returns true when a provider has been registered for the given name. + */ + hasProvider(provider: VerificationProvider): boolean { + return this.providers.has(provider); + } + + /** + * The default provider name resolved from configuration. + * Falls back to MOCK when unset or unknown. + */ + getDefaultProviderName(): VerificationProvider { + const configured = + this.configService.get('VERIFICATION_PROVIDER') ?? + this.configService.get('DEFAULT_VERIFICATION_PROVIDER'); + + if (!configured) { + return VerificationProvider.MOCK; + } + + const normalized = configured.toLowerCase() as VerificationProvider; + if (!Object.values(VerificationProvider).includes(normalized)) { + this.logger.warn( + `Unknown VERIFICATION_PROVIDER "${configured}", falling back to "${VerificationProvider.MOCK}"`, + ); + return VerificationProvider.MOCK; + } + + return normalized; + } + + /** + * Resolve a configured provider instance. When no explicit provider is + * requested, the configured default is used. + */ + getProvider(options: ProviderFactoryOptions = {}): IVerificationProvider { + const providerName = options.provider ?? this.getDefaultProviderName(); + const provider = this.providers.get(providerName); + + if (!provider) { + throw new Error(`Unsupported verification provider: ${providerName}`); + } + + // Apply resolved configuration if the provider supports it. + if (typeof provider.applyConfig === 'function') { + provider.applyConfig(this.resolveConfig(providerName, options.config)); + } + + return provider; + } + + /** + * Resolve provider credentials from explicit overrides then environment. + */ + private resolveConfig( + providerName: VerificationProvider, + overrides?: ProviderConfig, + ): ProviderConfig { + const prefix = providerName.toUpperCase(); + return { + apiKey: overrides?.apiKey ?? this.configService.get(`${prefix}_API_KEY`), + apiSecret: overrides?.apiSecret ?? this.configService.get(`${prefix}_API_SECRET`), + baseUrl: overrides?.baseUrl ?? this.configService.get(`${prefix}_BASE_URL`), + webhookSecret: + overrides?.webhookSecret ?? this.configService.get(`${prefix}_WEBHOOK_SECRET`), + timeoutMs: overrides?.timeoutMs ?? 30000, + }; + } + + 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..53029ac --- /dev/null +++ b/src/verification/providers/verification.provider.ts @@ -0,0 +1,72 @@ +import { + VerificationProvider, + VerificationType, + VerificationSubject, + ProviderCreateSessionResponse, + ProviderStatusResponse, + ProviderVerificationResult, + ProviderCancelResponse, + ProviderWebhookPayload, +} from '../dto/verification.dto'; + +/** + * Common contract every KYC/KYB identity provider must satisfy. + * + * Core services (e.g. VerificationService) depend ONLY on this interface, + * never on a concrete vendor SDK. This keeps business logic — including the + * Agreement Service — fully isolated from any single vendor and prevents + * vendor lock-in. + * + * Adding a new provider (Sumsub, Persona, Veriff, Synaps, Stripe Identity, + * Alloy, ...) is a matter of implementing this interface and registering the + * instance with the {@link VerificationProviderFactory}. No core code changes. + * + * The five lifecycle operations required by the abstraction are: + * - createSession -> Create Verification Session + * - getStatus -> Get Verification Status + * - getResults -> Retrieve Verification Results + * - handleWebhook -> Handle Verification Updates + * - cancelSession -> Cancel Verification + * + * `getResults` and `cancelSession` are optional so providers that do not + * support them (or lightweight test doubles) remain valid implementations. + * The service degrades gracefully when a capability is absent. + */ +export interface IVerificationProvider { + /** Canonical provider identifier. */ + readonly name: VerificationProvider; + /** Verification types (KYC/KYB) this provider can handle. */ + readonly supportedTypes: VerificationType[]; + + /** Create Verification Session. */ + createSession( + subject: VerificationSubject, + type: VerificationType, + ): Promise; + + /** Get Verification Status. */ + getStatus(sessionId: string): Promise; + + /** Retrieve Verification Results (normalized, vendor-agnostic). */ + getResults?(sessionId: string): Promise; + + /** Handle Verification Updates delivered via provider webhooks. */ + handleWebhook(payload: ProviderWebhookPayload): Promise; + + /** Cancel Verification. */ + cancelSession?(sessionId: string): Promise; + + /** Verify the authenticity of an inbound webhook. */ + validateWebhookSignature(payload: string, signature: string): boolean; + + /** Apply runtime configuration (API keys, secrets, base URLs, timeouts). */ + applyConfig?(config: ProviderConfig): void; +} + +export interface ProviderConfig { + apiKey?: string; + apiSecret?: string; + baseUrl?: string; + webhookSecret?: string; + timeoutMs?: number; +} diff --git a/src/verification/verification.abstraction.spec.ts b/src/verification/verification.abstraction.spec.ts new file mode 100644 index 0000000..25961e4 --- /dev/null +++ b/src/verification/verification.abstraction.spec.ts @@ -0,0 +1,265 @@ +import { ConfigService } from '@nestjs/config'; +import { MockVerificationProvider } from './providers/mock.provider'; +import { VerificationProviderFactory } from './providers/provider-factory'; +import { IVerificationProvider } from './providers/verification.provider'; +import { VerificationService } from './verification.service'; +import { SupabaseService } from '../supabase/supabase.service'; +import { + VerificationProvider, + VerificationSession, + VerificationStatus, + VerificationType, +} from './dto/verification.dto'; + +function buildConfigService(values: Record = {}): ConfigService { + return { + get: jest.fn((key: string) => values[key] ?? undefined), + } as unknown as ConfigService; +} + +describe('MockVerificationProvider', () => { + let provider: MockVerificationProvider; + + beforeEach(() => { + provider = new MockVerificationProvider(); + }); + + it('advertises identity and supported types', () => { + expect(provider.name).toBe(VerificationProvider.MOCK); + expect(provider.supportedTypes).toEqual([VerificationType.KYC, VerificationType.KYB]); + }); + + it('creates a verification session', async () => { + const session = await provider.createSession( + { type: VerificationType.KYC }, + VerificationType.KYC, + ); + + expect(session.provider_session_id).toBeDefined(); + expect(session.expires_at).toBeDefined(); + }); + + it('returns verification status', async () => { + const status = await provider.getStatus('mock-session'); + expect(status.status).toBe(VerificationStatus.COMPLETED); + }); + + it('retrieves normalized verification results', async () => { + const results = await provider.getResults('mock-session'); + + expect(results.status).toBe(VerificationStatus.COMPLETED); + expect(results.result?.risk_level).toBe('low'); + expect(results.completed_at).toBeDefined(); + }); + + it('cancels a verification session', async () => { + const cancelled = await provider.cancelSession('mock-session'); + + expect(cancelled.cancelled).toBe(true); + expect(cancelled.status).toBe(VerificationStatus.CANCELLED); + }); + + it('maps webhook events to statuses', async () => { + const result = await provider.handleWebhook({ + session_id: 'mock-session', + event: 'verification.failed', + status: VerificationStatus.PENDING, + timestamp: new Date().toISOString(), + }); + + expect(result.status).toBe(VerificationStatus.FAILED); + }); + + it('stores config via applyConfig and validates webhook signatures', () => { + provider.applyConfig({ webhookSecret: 'shhh' }); + + const payload = 'payload-body'; + const expected = `sha256=${Buffer.from(payload + 'shhh') + .toString('base64') + .slice(0, 43)}`; + + expect(provider.validateWebhookSignature(payload, expected)).toBe(true); + expect(provider.validateWebhookSignature(payload, 'wrong')).toBe(false); + }); + + it('supports the create-failure test hook', async () => { + provider.configure({ shouldFail: true, failOn: 'create' }); + + await expect( + provider.createSession({ type: VerificationType.KYC }, VerificationType.KYC), + ).rejects.toThrow('MOCK_PROVIDER_ERROR'); + }); +}); + +describe('VerificationProviderFactory', () => { + it('registers the mock provider by default', () => { + const factory = new VerificationProviderFactory(buildConfigService()); + expect(factory.hasProvider(VerificationProvider.MOCK)).toBe(true); + expect(factory.getSupportedProviders()).toContain(VerificationProvider.MOCK); + }); + + it('defaults to mock when no provider is configured', () => { + const factory = new VerificationProviderFactory(buildConfigService()); + expect(factory.getDefaultProviderName()).toBe(VerificationProvider.MOCK); + }); + + it('resolves the default provider from VERIFICATION_PROVIDER', () => { + const factory = new VerificationProviderFactory( + buildConfigService({ VERIFICATION_PROVIDER: 'persona' }), + ); + expect(factory.getDefaultProviderName()).toBe(VerificationProvider.PERSONA); + }); + + it('normalizes casing and falls back for unknown providers', () => { + const factory = new VerificationProviderFactory( + buildConfigService({ VERIFICATION_PROVIDER: 'NotAProvider' }), + ); + expect(factory.getDefaultProviderName()).toBe(VerificationProvider.MOCK); + }); + + it('throws when requesting an unregistered provider', () => { + const factory = new VerificationProviderFactory(buildConfigService()); + expect(() => factory.getProvider({ provider: VerificationProvider.SUMSUB })).toThrow( + 'Unsupported verification provider: sumsub', + ); + }); + + it('applies resolved config to providers that support applyConfig', () => { + const factory = new VerificationProviderFactory( + buildConfigService({ MOCK_WEBHOOK_SECRET: 'from-env' }), + ); + + const spy = jest.spyOn(MockVerificationProvider.prototype, 'applyConfig'); + const provider = factory.getProvider({ provider: VerificationProvider.MOCK }); + + expect(provider).toBeDefined(); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ webhookSecret: 'from-env', timeoutMs: 30000 }), + ); + spy.mockRestore(); + }); + + it('allows registering and resolving new vendor providers at runtime', () => { + const factory = new VerificationProviderFactory(buildConfigService()); + + const personaLike: IVerificationProvider = { + name: VerificationProvider.PERSONA, + supportedTypes: [VerificationType.KYC], + createSession: jest.fn(), + getStatus: jest.fn(), + handleWebhook: jest.fn(), + validateWebhookSignature: jest.fn().mockReturnValue(true), + }; + + factory.registerProvider(personaLike); + + expect(factory.hasProvider(VerificationProvider.PERSONA)).toBe(true); + expect(factory.getProvider({ provider: VerificationProvider.PERSONA })).toBe(personaLike); + }); +}); + +/** + * Minimal single-row Supabase stub that supports the fluent chain the service + * uses: from().select().eq().eq().maybeSingle() and from().update().eq(). + */ +function buildSupabaseStub(row: VerificationSession | null) { + let current: VerificationSession | null = row ? { ...row } : null; + const captured: { updates?: Record } = {}; + + interface QueryStub { + select: () => QueryStub; + update: (payload: Record) => QueryStub; + eq: () => QueryStub; + maybeSingle: () => Promise<{ data: VerificationSession | null; error: null }>; + then: (resolve: (v: { data: null; error: null }) => unknown) => unknown; + } + + const builder: QueryStub = { + select: () => builder, + update: (payload: Record) => { + captured.updates = payload; + if (current) current = { ...current, ...payload }; + return builder; + }, + eq: () => builder, + maybeSingle: () => Promise.resolve({ data: current, error: null }), + then: (resolve: (v: { data: null; error: null }) => unknown) => + resolve({ data: null, error: null }), + }; + + const supabase = { + getClient: () => ({ from: () => builder }), + } as unknown as SupabaseService; + + return { supabase, captured, getRow: () => current }; +} + +function baseSession(overrides: Partial = {}): VerificationSession { + return { + id: 'session-1', + provider: VerificationProvider.MOCK, + type: VerificationType.KYC, + subject: { type: VerificationType.KYC }, + status: VerificationStatus.PENDING, + provider_session_id: 'mock-provider-session', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('VerificationService results & cancellation', () => { + const USER = 'user-1'; + + function buildService(supabase: SupabaseService, factory: VerificationProviderFactory) { + return new VerificationService(supabase, buildConfigService(), factory); + } + + it('retrieves and persists verification results', async () => { + const { supabase, captured } = buildSupabaseStub(baseSession()); + const factory = new VerificationProviderFactory(buildConfigService()); + const service = buildService(supabase, factory); + + const { session, error } = await service.getResults(USER, 'session-1'); + + expect(error).toBeNull(); + expect(session?.status).toBe(VerificationStatus.COMPLETED); + expect(captured.updates?.status).toBe(VerificationStatus.COMPLETED); + expect(captured.updates?.result).toBeDefined(); + expect(captured.updates?.completed_at).toBeDefined(); + }); + + it('returns not found when the session is missing', async () => { + const { supabase } = buildSupabaseStub(null); + const factory = new VerificationProviderFactory(buildConfigService()); + const service = buildService(supabase, factory); + + const { session, error } = await service.getResults(USER, 'missing'); + + expect(session).toBeNull(); + expect(error).toBe('Verification session not found'); + }); + + it('cancels a pending session and marks it cancelled', async () => { + const { supabase, captured } = buildSupabaseStub(baseSession()); + const factory = new VerificationProviderFactory(buildConfigService()); + const service = buildService(supabase, factory); + + const { session, error } = await service.cancelSession(USER, 'session-1'); + + expect(error).toBeNull(); + expect(session?.status).toBe(VerificationStatus.CANCELLED); + expect(captured.updates?.status).toBe(VerificationStatus.CANCELLED); + expect(captured.updates?.cancelled_at).toBeDefined(); + }); + + it('refuses to cancel a terminal session', async () => { + const { supabase } = buildSupabaseStub(baseSession({ status: VerificationStatus.COMPLETED })); + const factory = new VerificationProviderFactory(buildConfigService()); + const service = buildService(supabase, factory); + + const { error } = await service.cancelSession(USER, 'session-1'); + + expect(error).toContain('Cannot cancel'); + }); +}); diff --git a/src/verification/verification.controller.ts b/src/verification/verification.controller.ts new file mode 100644 index 0000000..cce0e2c --- /dev/null +++ b/src/verification/verification.controller.ts @@ -0,0 +1,121 @@ +import { + Body, + Controller, + Delete, + 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, + VerificationProvider, + VerificationType, + type ProviderWebhookPayload, +} 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('sessions/:id/results') + @UseGuards(JwtAuthGuard) + async getResults(@CurrentUser() user: AuthUserCtx, @Param('id') sessionId: string) { + const result = await this.verificationService.getResults(user.userId, sessionId); + + if (result.error) { + throw new BadRequestException(result.error); + } + + return { session: result.session, error: null }; + } + + @Delete('sessions/:id') + @UseGuards(JwtAuthGuard) + async cancelSession(@CurrentUser() user: AuthUserCtx, @Param('id') sessionId: string) { + const result = await this.verificationService.cancelSession(user.userId, sessionId); + + if (result.error) { + throw new BadRequestException(result.error); + } + + return { session: result.session, error: null }; + } + + @Post('webhooks/:provider') + async handleWebhook( + @Param('provider') provider: VerificationProvider, + @Body() payload: ProviderWebhookPayload, + ) { + const result = await this.verificationService.handleWebhook(provider, payload); + + if (result.error) { + throw new BadRequestException(result.error); + } + + return { handled: result.handled, 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..19b5086 --- /dev/null +++ b/src/verification/verification.service.ts @@ -0,0 +1,410 @@ +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 ?? this.providerFactory.getDefaultProviderName(); + 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 }; + } + + /** + * Retrieve Verification Results. + * + * Fetches the normalized verification outcome from the provider and persists + * it against the local session. Falls back to the stored result when the + * provider does not expose a dedicated results endpoint. + */ + async getResults( + 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; + + if (!existingSession.provider_session_id) { + return { session: existingSession, error: null }; + } + + const provider = this.providerFactory.getProvider({ provider: existingSession.provider }); + + // Provider does not support a dedicated results operation: return stored data. + if (typeof provider.getResults !== 'function') { + return { session: existingSession, error: null }; + } + + try { + const providerResult = await provider.getResults(existingSession.provider_session_id); + + const updates: Record = { + status: providerResult.status, + updated_at: new Date().toISOString(), + }; + + if (providerResult.result) { + updates.result = providerResult.result; + } + if (providerResult.error) { + updates.error = providerResult.error; + } + if (providerResult.status === VerificationStatus.COMPLETED) { + updates.completed_at = providerResult.completed_at ?? new Date().toISOString(); + } + + await this.supabase + .getClient() + .from('verification_sessions') + .update(updates) + .eq('id', sessionId); + + Object.assign(existingSession, updates); + + return { session: existingSession, error: null }; + } catch (err) { + return { + session: existingSession, + error: err instanceof Error ? err.message : 'Failed to retrieve verification results', + }; + } + } + + /** + * Cancel Verification. + * + * Requests cancellation from the provider (when supported) and marks the + * local session as cancelled. Terminal sessions cannot be cancelled. + */ + async cancelSession( + 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; + + const terminalStatuses: VerificationStatus[] = [ + VerificationStatus.COMPLETED, + VerificationStatus.FAILED, + VerificationStatus.EXPIRED, + VerificationStatus.CANCELLED, + ]; + + if (terminalStatuses.includes(existingSession.status)) { + return { + session: existingSession, + error: `Cannot cancel a session in "${existingSession.status}" state`, + }; + } + + const provider = this.providerFactory.getProvider({ provider: existingSession.provider }); + + try { + // Best-effort provider-side cancellation when supported. + if (existingSession.provider_session_id && typeof provider.cancelSession === 'function') { + await provider.cancelSession(existingSession.provider_session_id); + } + + const now = new Date().toISOString(); + const updates = { + status: VerificationStatus.CANCELLED, + cancelled_at: now, + updated_at: now, + }; + + await this.supabase + .getClient() + .from('verification_sessions') + .update(updates) + .eq('id', sessionId); + + Object.assign(existingSession, updates); + + return { session: existingSession, error: null }; + } catch (err) { + return { + session: existingSession, + error: err instanceof Error ? err.message : 'Failed to cancel verification session', + }; + } + } + + 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,