Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBh
# THALOS_INTERNAL_SECRET.
THALOS_INTERNAL_SECRET="xeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30yz"

# ---- KYC Provider (optional, defaults to mock provider) ----
# KYC_PROVIDER="mock-kyc"

# ---- Trustless Work (required for escrow operations) --------

# Base URL of the Trustless Work API.
Expand Down
31 changes: 31 additions & 0 deletions scripts/002_create_kyc_verifications.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
-- Migration: Create kyc_verifications table for KYC provider integration
-- EXECUTED: Apply to Supabase

CREATE TYPE kyc_status AS ENUM ('pending', 'in_review', 'verified', 'rejected', 'expired');

CREATE TABLE IF NOT EXISTS public.kyc_verifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_verification_id TEXT NOT NULL UNIQUE,
status kyc_status NOT NULL DEFAULT 'pending',
metadata JSONB DEFAULT '{}',
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_kyc_verifications_user_id ON public.kyc_verifications(user_id);
CREATE INDEX IF NOT EXISTS idx_kyc_verifications_provider_verification_id ON public.kyc_verifications(provider_verification_id);
CREATE INDEX IF NOT EXISTS idx_kyc_verifications_status ON public.kyc_verifications(status);

ALTER TABLE public.kyc_verifications ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can view their own KYC verifications" ON public.kyc_verifications FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Service role can insert KYC verifications" ON public.kyc_verifications FOR INSERT WITH CHECK (true);
CREATE POLICY "Service role can update KYC verifications" ON public.kyc_verifications FOR UPDATE USING (true);

COMMENT ON TABLE public.kyc_verifications IS 'Stores KYC verification sessions created through the identity provider abstraction.';
COMMENT ON COLUMN public.kyc_verifications.provider IS 'Name of the KYC provider that handled this verification';
COMMENT ON COLUMN public.kyc_verifications.provider_verification_id IS 'Provider-side verification session identifier';
COMMENT ON COLUMN public.kyc_verifications.status IS 'Current verification status: pending, in_review, verified, rejected, expired';
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +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 { KycModule } from './kyc/kyc.module';
import { KybModule } from './kyb/kyb.module';

@Module({
Expand All @@ -36,6 +37,7 @@ import { KybModule } from './kyb/kyb.module';
WalletsModule,
EventsModule,
WebhooksModule,
KycModule,
KybModule,
],
controllers: [RootController],
Expand Down
21 changes: 21 additions & 0 deletions src/kyc/dto/kyc.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { IsOptional, IsString, IsObject } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class CreateKycSessionDto {
@ApiPropertyOptional({
description: 'Optional metadata to attach to the verification session',
})
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}

export class KycWebhookDto {
@ApiProperty({ description: 'Provider verification ID' })
@IsString()
verification_id: string;

@ApiProperty({ description: 'New verification status' })
@IsString()
status: string;
}
29 changes: 29 additions & 0 deletions src/kyc/interfaces/identity-provider.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { KycStatus } from './kyc.types';

export interface CreateSessionInput {
userId: string;
metadata?: Record<string, unknown>;
}

export interface VerificationResult {
status: KycStatus;
verifiedAt: string | null;
metadata?: Record<string, unknown>;
}

export interface IIdentityProvider {
readonly name: string;

createSession(input: CreateSessionInput): Promise<{
providerVerificationId: string;
sessionUrl?: string;
metadata?: Record<string, unknown>;
}>;

getStatus(providerVerificationId: string): Promise<VerificationResult>;

processWebhook(payload: unknown): Promise<{
providerVerificationId: string;
result: VerificationResult;
}>;
}
31 changes: 31 additions & 0 deletions src/kyc/interfaces/kyc.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export enum KycStatus {
Pending = 'pending',
InReview = 'in_review',
Verified = 'verified',
Rejected = 'rejected',
Expired = 'expired',
}

export interface KycSession {
id: string;
userId: string;
provider: string;
providerVerificationId: string;
status: KycStatus;
metadata: Record<string, unknown>;
verifiedAt: string | null;
createdAt: string;
updatedAt: string;
}

export interface KycVerificationRow {
id: string;
user_id: string;
provider: string;
provider_verification_id: string;
status: KycStatus;
metadata: Record<string, unknown>;
verified_at: string | null;
created_at: string;
updated_at: string;
}
36 changes: 36 additions & 0 deletions src/kyc/kyc.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser, AuthUserCtx } from '../auth/current-user.decorator';
import { KycService } from './kyc.service';
import { CreateKycSessionDto, KycWebhookDto } from './dto/kyc.dto';

@ApiTags('kyc')
@Controller('kyc')
export class KycController {
constructor(private readonly kycService: KycService) {}

@Post('session')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('bearer')
@ApiOperation({ summary: 'Create a new KYC verification session' })
@ApiResponse({ status: 201, description: 'KYC session created' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async createSession(@CurrentUser() user: AuthUserCtx, @Body() dto: CreateKycSessionDto) {
return this.kycService.createSession(user.userId, dto.metadata);
}

@Get('status/:userId')
@ApiOperation({ summary: 'Get KYC verification status for a user' })
@ApiResponse({ status: 200, description: 'KYC status retrieved' })
async getStatus(@Param('userId') userId: string) {
return this.kycService.getStatus(userId);
}

@Post('webhook')
@ApiOperation({ summary: 'Receive KYC verification results from provider' })
@ApiResponse({ status: 201, description: 'Webhook processed' })
async handleWebhook(@Body() dto: KycWebhookDto) {
return this.kycService.handleWebhook(dto);
}
}
14 changes: 14 additions & 0 deletions src/kyc/kyc.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { KycController } from './kyc.controller';
import { KycService } from './kyc.service';
import { MockKycProvider } from './providers/mock-kyc.provider';
import { SupabaseModule } from '../supabase/supabase.module';
import { AuthModule } from '../auth/auth.module';

@Module({
imports: [SupabaseModule, AuthModule],
controllers: [KycController],
providers: [KycService, MockKycProvider],
exports: [KycService],
})
export class KycModule {}
139 changes: 139 additions & 0 deletions src/kyc/kyc.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { SupabaseService } from '../supabase/supabase.service';
import { IIdentityProvider } from './interfaces/identity-provider.interface';
import { MockKycProvider } from './providers/mock-kyc.provider';
import { KycStatus, KycSession, KycVerificationRow } from './interfaces/kyc.types';

@Injectable()
export class KycService {
private readonly logger = new Logger(KycService.name);
private readonly provider: IIdentityProvider;

constructor(
private readonly supabase: SupabaseService,
mockProvider: MockKycProvider,
) {
this.provider = mockProvider;
this.logger.log(`Initialized KYC service with provider: ${this.provider.name}`);
}

get activeProvider(): string {
return this.provider.name;
}

async createSession(userId: string, metadata?: Record<string, unknown>) {
const { providerVerificationId, sessionUrl } = await this.provider.createSession({
userId,
metadata,
});

const row: Omit<KycVerificationRow, 'id' | 'created_at' | 'updated_at'> = {
user_id: userId,
provider: this.provider.name,
provider_verification_id: providerVerificationId,
status: KycStatus.Pending,
metadata: metadata || {},
verified_at: null,
};

const { data, error } = await this.supabase
.getClient()
.from('kyc_verifications')
.insert(row)
.select()
.single();

if (error) {
this.logger.error(`Failed to persist KYC session: ${error.message}`);
throw new Error(`Failed to create KYC session: ${error.message}`);
}

return {
session: this.toSession(data as KycVerificationRow),
sessionUrl,
};
}

async getStatus(userId: string) {
const { data, error } = await this.supabase
.getClient()
.from('kyc_verifications')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();

if (error) {
throw new Error(`Failed to fetch KYC status: ${error.message}`);
}

if (!data) {
return { session: null };
}

const row = data as KycVerificationRow;
const providerResult = await this.provider.getStatus(row.provider_verification_id);

if (providerResult.status !== row.status) {
const { data: updated } = await this.supabase
.getClient()
.from('kyc_verifications')
.update({
status: providerResult.status,
verified_at: providerResult.verifiedAt,
updated_at: new Date().toISOString(),
})
.eq('id', row.id)
.select()
.single();

if (updated) {
return { session: this.toSession(updated as KycVerificationRow) };
}
}

return { session: this.toSession(row) };
}

async handleWebhook(payload: unknown): Promise<KycSession> {
const { providerVerificationId, result } = await this.provider.processWebhook(payload);

const { data, error } = await this.supabase
.getClient()
.from('kyc_verifications')
.update({
status: result.status,
verified_at: result.verifiedAt,
metadata: result.metadata ? { ...result.metadata } : undefined,
updated_at: new Date().toISOString(),
})
.eq('provider_verification_id', providerVerificationId)
.select()
.single();

if (error || !data) {
this.logger.error(`Failed to update KYC session from webhook: ${error?.message}`);
throw new NotFoundException('KYC session not found');
}

this.logger.log(
`KYC session ${providerVerificationId} updated via webhook to ${result.status}`,
);
return this.toSession(data as KycVerificationRow);
}

private toSession(row: KycVerificationRow): KycSession {
return {
id: row.id,
userId: row.user_id,
provider: row.provider,
providerVerificationId: row.provider_verification_id,
status: row.status,
metadata: row.metadata || {},
verifiedAt: row.verified_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}
Loading
Loading