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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ JWT_ACCESS_EXPIRATION=15m
JWT_REFRESH_EXPIRATION=7d
NONCE_EXPIRATION=300

# Comma-separated Stellar public keys allowed to use admin endpoints.
# Empty or unset denies all admin access by design.
ADMIN_WALLETS=

# Redis
REDIS_URL=redis://localhost:6379
REDIS_DB=0
Expand Down
12 changes: 12 additions & 0 deletions context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ pure chore/docs commits). Direct pushes to main must also be logged here.

---

## 2026-07-22

- Added a fail-closed `ADMIN_WALLETS` allowlist validated as Stellar public
keys at startup, plus a shared `AdminGuard` for privileged API endpoints.
The audit-log controller now requires both a valid JWT and an allowlisted
wallet; self-selected user roles remain unchanged and cannot grant admin
access.
- Added admin-only vendor approval and suspension XDR endpoints backed by the
vendor-registry contract. Vendor lifecycle state is mirrored locally only
after the existing transaction status checker confirms the signed
transaction on Stellar.

## 2026-07-19

- Wired `LiquidityContractClient` (restored under `src/blockchain/contracts/liquidity-contract.client.ts`) into `LiquidityService` constructor.
Expand Down
3 changes: 2 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ import { CreditScoringModule } from './modules/credit-scoring/credit-scoring.mod
import { AdminModule } from './modules/admin/admin.module';
import { CorrelationIdMiddleware } from './common/logger/correlation-id.middleware';
import { StateReconciliationModule } from './jobs/state-reconciliation/state-reconciliation.module';
import { validateEnvironment } from './config/env';

@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ConfigModule.forRoot({ isGlobal: true, validate: validateEnvironment }),
SentryModule.forRoot(),
ScheduleModule.forRoot(),
LoggerModule,
Expand Down
38 changes: 38 additions & 0 deletions src/auth/guards/admin.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { parseAdminWallets } from '../../config/env';

interface AuthenticatedRequest {
user?: { wallet?: string };
}

@Injectable()
export class AdminGuard implements CanActivate {
private readonly logger = new Logger(AdminGuard.name);
private readonly adminWallets: ReadonlySet<string>;

constructor(configService: ConfigService) {
this.adminWallets = new Set(parseAdminWallets(configService.get<string>('ADMIN_WALLETS')));
}

canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const wallet = request.user?.wallet;

if (!wallet || !this.adminWallets.has(wallet)) {
this.logger.warn(`Admin access denied for wallet ${wallet ?? 'unknown'}`);
throw new ForbiddenException({
code: 'AUTH_ADMIN_FORBIDDEN',
message: 'Admin access required.',
});
}

return true;
}
}
24 changes: 24 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { StrKey } from 'stellar-sdk';

export function parseAdminWallets(value: unknown): string[] {
if (typeof value !== 'string' || value.trim() === '') return [];

const wallets = value
.split(',')
.map((wallet) => wallet.trim())
.filter((wallet) => wallet.length > 0);

const invalidWallet = wallets.find((wallet) => !StrKey.isValidEd25519PublicKey(wallet));
if (invalidWallet) {
throw new Error(`ADMIN_WALLETS contains an invalid Stellar public key: ${invalidWallet}`);
}

return [...new Set(wallets)];
}

export function validateEnvironment(config: Record<string, unknown>): Record<string, unknown> {
return {
...config,
ADMIN_WALLETS: parseAdminWallets(config.ADMIN_WALLETS).join(','),
};
}
2 changes: 2 additions & 0 deletions src/database/repositories/vendors.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { SupabaseService } from '../supabase.client';

export type VendorType = 'school' | 'bootcamp' | 'electronics' | 'books' | 'subscriptions';
export type VendorStatus = 'pending' | 'approved' | 'suspended' | 'rejected';

export interface VendorRecord {
id: string;
wallet_address: string | null;
name: string;
type: VendorType;
verified: boolean;
status: VendorStatus;
}

export interface VendorDetailRecord extends VendorRecord {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ interface PendingTransaction {
interface TransactionMetadata {
loanId?: string;
amount?: number;
vendorWallet?: string;
vendorStatus?: 'approved' | 'suspended';
}

interface TransactionStatusResult {
Expand All @@ -32,6 +34,8 @@ interface FollowUpResult {
loanId?: string;
remainingBalance?: number;
loanStatus?: string;
vendorWallet?: string;
vendorStatus?: 'approved' | 'suspended';
}

/**
Expand Down Expand Up @@ -351,10 +355,21 @@ export class TransactionStatusCheckerService {
}

const metadata = this.parseTransactionMetadata(transaction.xdr);
if (!metadata?.loanId) {
if (!metadata) {
return {};
}

if (
(transaction.type === TransactionType.VENDOR_APPROVE ||
transaction.type === TransactionType.VENDOR_SUSPEND) &&
metadata.vendorWallet &&
metadata.vendorStatus
) {
return this.applyVendorStatus(metadata.vendorWallet, metadata.vendorStatus);
}

if (!metadata.loanId) return {};

if (transaction.type === TransactionType.LOAN_CREATE) {
return this.activatePendingLoan(metadata.loanId, transaction.user_wallet);
}
Expand All @@ -366,6 +381,55 @@ export class TransactionStatusCheckerService {
return { loanId: metadata.loanId };
}

private async applyVendorStatus(
vendorWallet: string,
status: 'approved' | 'suspended',
): Promise<FollowUpResult> {
const db = this.supabaseService.getServiceRoleClient();
const expectedStatus = status === 'approved' ? 'pending' : 'approved';
const { data, error } = await db
.from('vendors')
.update({
status,
verified: status === 'approved',
updated_at: new Date().toISOString(),
})
.eq('wallet_address', vendorWallet)
.eq('status', expectedStatus)
.select('id')
.maybeSingle();

if (error) {
this.logger.warn(
{
context: 'TransactionStatusCheckerService',
action: 'applyVendorStatus',
vendorWallet,
status,
error: error.message,
},
'Failed to update vendor status after successful transaction',
);
return { vendorWallet };
}

if (!data) {
this.logger.warn(
{
context: 'TransactionStatusCheckerService',
action: 'applyVendorStatus',
vendorWallet,
status,
expectedStatus,
},
'Vendor status was not updated because its local state no longer matched',
);
return { vendorWallet };
}

return { vendorWallet, vendorStatus: status };
}

private async activatePendingLoan(
loanId: string,
userWallet: string,
Expand Down Expand Up @@ -544,6 +608,15 @@ export class TransactionStatusCheckerService {
return { loanId, amount };
}

if (functionName === 'approve_vendor' || functionName === 'suspend_vendor') {
const vendorWallet = this.contractAddressToString(nativeArgs[1]);
if (!vendorWallet) return null;
return {
vendorWallet,
vendorStatus: functionName === 'approve_vendor' ? 'approved' : 'suspended',
};
}

return null;
} catch (error) {
this.logger.warn(
Expand All @@ -559,6 +632,15 @@ export class TransactionStatusCheckerService {
}
}

private contractAddressToString(value: unknown): string | undefined {
if (typeof value === 'string') return value;
if (value && typeof value === 'object' && 'toString' in value) {
const address = String(value);
return address.startsWith('G') ? address : undefined;
}
return undefined;
}

private async createNotification(
transaction: PendingTransaction,
status: 'success' | 'failed',
Expand Down Expand Up @@ -638,6 +720,20 @@ export class TransactionStatusCheckerService {
};
}

if (
transaction.type === TransactionType.VENDOR_APPROVE ||
transaction.type === TransactionType.VENDOR_SUSPEND
) {
const approved = transaction.type === TransactionType.VENDOR_APPROVE;
return {
type: approved ? 'vendor_approve_success' : 'vendor_suspend_success',
title: approved ? 'Vendor Approved' : 'Vendor Suspended',
message: followUp.vendorStatus
? `Vendor status was updated to ${followUp.vendorStatus} after Stellar confirmation.`
: 'The vendor status transaction was confirmed on Stellar.',
};
}

return {
type: 'transaction_success',
title: 'Transaction Confirmed',
Expand Down
4 changes: 3 additions & 1 deletion src/modules/admin/audit.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import { AuditLogListResponseDto } from './dto/audit-log-response.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
import { AuditAction } from '../../common/decorators/audit-action.decorator';
import { AdminGuard } from '../../auth/guards/admin.guard';

@ApiTags('admin')
@Controller('admin')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, AdminGuard)
@ApiBearerAuth()
export class AuditController {
constructor(private readonly auditService: AuditService) {}
Expand All @@ -35,6 +36,7 @@ export class AuditController {
type: AuditLogListResponseDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid admin JWT' })
@ApiResponse({ status: 403, description: 'Forbidden - wallet is not an administrator' })
async getAuditLogs(@Query() query: AuditLogQueryDto) {
return this.auditService.findMany(query);
}
Expand Down
5 changes: 3 additions & 2 deletions src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ApiKeyGuard } from '../../auth/guards/api-key.guard';
import { SupabaseService } from '../../database/supabase.client';
import { UsersRepository } from '../../database/repositories/users.repository';
import { getJwtConfig } from '../../config/jwt.config';
import { AdminGuard } from '../../auth/guards/admin.guard';

@Module({
imports: [
Expand All @@ -20,7 +21,7 @@ import { getJwtConfig } from '../../config/jwt.config';
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, ApiKeyGuard, SupabaseService, ConfigService, UsersRepository],
exports: [AuthService, JwtStrategy, ApiKeyGuard, PassportModule],
providers: [AuthService, JwtStrategy, ApiKeyGuard, AdminGuard, SupabaseService, ConfigService, UsersRepository],
exports: [AuthService, JwtStrategy, ApiKeyGuard, AdminGuard, PassportModule],
})
export class AuthModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export enum TransactionType {
LOAN_REPAY = 'loan_repay',
DEPOSIT = 'deposit',
WITHDRAW = 'withdraw',
VENDOR_APPROVE = 'vendor_approve',
VENDOR_SUSPEND = 'vendor_suspend',
}

/**
Expand Down
22 changes: 22 additions & 0 deletions src/modules/vendors/dto/vendor.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ export enum VendorType {
SUBSCRIPTIONS = 'subscriptions',
}

export enum VendorStatus {
PENDING = 'pending',
APPROVED = 'approved',
SUSPENDED = 'suspended',
REJECTED = 'rejected',
}

export class CreateVendorDto {
@ApiProperty({ example: 'University of Lagos' })
@IsString()
Expand Down Expand Up @@ -40,9 +47,24 @@ export class VendorResponseDto {
@ApiProperty() name: string;
@ApiProperty({ enum: VendorType }) type: VendorType;
@ApiProperty() verified: boolean;
@ApiProperty({ enum: VendorStatus }) status: VendorStatus;
@ApiPropertyOptional() website?: string;
@ApiPropertyOptional() country?: string;
@ApiPropertyOptional() city?: string;
@ApiPropertyOptional() description?: string;
@ApiProperty() createdAt: string;
}

export class VendorStatusChangeResponseDto {
@ApiProperty({ description: 'Unsigned transaction XDR for the admin wallet to sign' })
unsignedXdr: string;

@ApiProperty({ example: 'Approve vendor Acme University' })
description: string;

@ApiProperty({ description: 'Local vendor UUID' })
vendorId: string;

@ApiProperty({ enum: VendorStatus })
targetStatus: VendorStatus;
}
Loading
Loading