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
32 changes: 32 additions & 0 deletions src/lib/kyc-status-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { truncateWalletAddress } from "./kyc";
/** The administrative action that produced a KYC status change. */
export type KYCStatusChangeAction = "approve" | "reject" | "revoke";

/** The stable category of a KYC review failure. */
export type KYCReviewFailureCategory = "unauthorized" | "user_not_found" | "invalid_state" | "database_error";

export interface KYCStatusChangeInput {
/** Wallet address of the user whose KYC status changed. */
wallet: string;
Expand Down Expand Up @@ -51,3 +54,32 @@ export function logKYCStatusChange(logger: AppLogger, input: KYCStatusChangeInpu
...(input.reason ? { reason: input.reason } : {}),
});
}

export interface KYCReviewFailureInput {
/** Internal id of the user being reviewed, if known. */
userId?: string;
/** Internal id of the reviewing admin, if known. */
reviewerId?: string;
/** Which admin action failed. */
action: KYCStatusChangeAction;
/** The stable category of the failure. */
failureCategory: KYCReviewFailureCategory;
/** A correlation identifier for the request, if available. */
correlationId?: string;
/** Any error message or code. */
errorDetails?: string;
}

/**
* Audit log for failed KYC review decisions.
*/
export function logKYCReviewFailure(logger: AppLogger, input: KYCReviewFailureInput): void {
logger.error("KYC review failed", {
...(input.userId ? { user_id: input.userId } : {}),
...(input.reviewerId ? { reviewer_id: input.reviewerId } : {}),
action: input.action,
failure_category: input.failureCategory,
...(input.correlationId ? { correlation_id: input.correlationId } : {}),
...(input.errorDetails ? { error_details: input.errorDetails } : {}),
});
}
28 changes: 27 additions & 1 deletion src/routes/admin/approve-kyc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Request, Response } from "express";
import { DataSource } from "typeorm";
import { User } from "@/models/User.model";
import { KYCStatus } from "@/types/enums";
import { logKYCStatusChange } from "@/lib/kyc-status-log";
import { logKYCStatusChange, logKYCReviewFailure } from "@/lib/kyc-status-log";
import { logger } from "@/observability/logger";

interface ApproveKYCBody {
Expand All @@ -13,7 +13,16 @@ interface ApproveKYCBody {
export async function approveKYC(req: Request<unknown, unknown, ApproveKYCBody>, res: Response, dataSource: DataSource) {
try {
const adminKey = req.headers["x-admin-key"];
const correlationId = req.headers["x-request-id"] as string | undefined;

if (adminKey !== process.env.ADMIN_API_KEY) {
logKYCReviewFailure(logger, {
userId: req.body?.userId,
reviewerId: req.body?.reviewerId,
action: "approve",
failureCategory: "unauthorized",
correlationId,
});
return res.status(401).json({ error: "Unauthorized" });
}

Expand All @@ -22,6 +31,13 @@ export async function approveKYC(req: Request<unknown, unknown, ApproveKYCBody>,
const userRepo = dataSource.getRepository(User);
const user = await userRepo.findOneBy({ id: userId });
if (!user) {
logKYCReviewFailure(logger, {
userId,
reviewerId,
action: "approve",
failureCategory: "user_not_found",
correlationId,
});
return res.status(404).json({ error: "User not found" });
}

Expand All @@ -45,6 +61,16 @@ export async function approveKYC(req: Request<unknown, unknown, ApproveKYCBody>,

return res.json({ success: true });
} catch (err: unknown) {
const correlationId = req.headers["x-request-id"] as string | undefined;
logKYCReviewFailure(logger, {
userId: req.body?.userId,
reviewerId: req.body?.reviewerId,
action: "approve",
failureCategory: "database_error",
correlationId,
errorDetails: err instanceof Error ? err.message : String(err),
});

const appErr = err as { status?: number; code?: string; message?: string };
return res.status(appErr.status ?? 500).json({
error: {
Expand Down
28 changes: 27 additions & 1 deletion src/routes/admin/reject-kyc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Request, Response } from "express";
import { DataSource } from "typeorm";
import { User } from "@/models/User.model";
import { KYCStatus } from "@/types/enums";
import { logKYCStatusChange } from "@/lib/kyc-status-log";
import { logKYCStatusChange, logKYCReviewFailure } from "@/lib/kyc-status-log";
import { logger } from "@/observability/logger";

interface RejectKYCBody {
Expand All @@ -14,7 +14,16 @@ interface RejectKYCBody {
export async function rejectKYC(req: Request<unknown, unknown, RejectKYCBody>, res: Response, dataSource: DataSource) {
try {
const adminKey = req.headers["x-admin-key"];
const correlationId = req.headers["x-request-id"] as string | undefined;

if (adminKey !== process.env.ADMIN_API_KEY) {
logKYCReviewFailure(logger, {
userId: req.body?.userId,
reviewerId: req.body?.reviewerId,
action: "reject",
failureCategory: "unauthorized",
correlationId,
});
return res.status(401).json({ error: "Unauthorized" });
}

Expand All @@ -23,6 +32,13 @@ export async function rejectKYC(req: Request<unknown, unknown, RejectKYCBody>, r
const userRepo = dataSource.getRepository(User);
const user = await userRepo.findOneBy({ id: userId });
if (!user) {
logKYCReviewFailure(logger, {
userId,
reviewerId,
action: "reject",
failureCategory: "user_not_found",
correlationId,
});
return res.status(404).json({ error: "User not found" });
}

Expand All @@ -47,6 +63,16 @@ export async function rejectKYC(req: Request<unknown, unknown, RejectKYCBody>, r

return res.json({ success: true });
} catch (err: unknown) {
const correlationId = req.headers["x-request-id"] as string | undefined;
logKYCReviewFailure(logger, {
userId: req.body?.userId,
reviewerId: req.body?.reviewerId,
action: "reject",
failureCategory: "database_error",
correlationId,
errorDetails: err instanceof Error ? err.message : String(err),
});

const appErr = err as { status?: number; code?: string; message?: string };
return res.status(appErr.status ?? 500).json({
error: {
Expand Down
36 changes: 35 additions & 1 deletion src/routes/admin/revoke-kyc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Request, Response } from "express";
import { DataSource } from "typeorm";
import { User } from "@/models/User.model";
import { KYCStatus } from "@/types/enums";
import { logKYCStatusChange } from "@/lib/kyc-status-log";
import { logKYCStatusChange, logKYCReviewFailure } from "@/lib/kyc-status-log";
import { logger } from "@/observability/logger";

interface RevokeKYCBody {
Expand All @@ -23,7 +23,16 @@ interface RevokeKYCBody {
export async function revokeKYC(req: Request<unknown, unknown, RevokeKYCBody>, res: Response, dataSource: DataSource) {
try {
const adminKey = req.headers["x-admin-key"];
const correlationId = req.headers["x-request-id"] as string | undefined;

if (adminKey !== process.env.ADMIN_API_KEY) {
logKYCReviewFailure(logger, {
userId: req.body?.userId,
reviewerId: req.body?.reviewerId,
action: "revoke",
failureCategory: "unauthorized",
correlationId,
});
return res.status(401).json({ error: "Unauthorized" });
}

Expand All @@ -32,11 +41,26 @@ export async function revokeKYC(req: Request<unknown, unknown, RevokeKYCBody>, r
const userRepo = dataSource.getRepository(User);
const user = await userRepo.findOneBy({ id: userId });
if (!user) {
logKYCReviewFailure(logger, {
userId,
reviewerId,
action: "revoke",
failureCategory: "user_not_found",
correlationId,
});
return res.status(404).json({ error: "User not found" });
}

const previousStatus = user.kycStatus;
if (previousStatus !== KYCStatus.APPROVED) {
logKYCReviewFailure(logger, {
userId,
reviewerId,
action: "revoke",
failureCategory: "invalid_state",
correlationId,
errorDetails: `Cannot revoke KYC for a user whose status is ${previousStatus}.`,
});
return res.status(409).json({
error: {
code: "KYC_NOT_APPROVED",
Expand All @@ -63,6 +87,16 @@ export async function revokeKYC(req: Request<unknown, unknown, RevokeKYCBody>, r

return res.json({ success: true });
} catch (err: unknown) {
const correlationId = req.headers["x-request-id"] as string | undefined;
logKYCReviewFailure(logger, {
userId: req.body?.userId,
reviewerId: req.body?.reviewerId,
action: "revoke",
failureCategory: "database_error",
correlationId,
errorDetails: err instanceof Error ? err.message : String(err),
});

const appErr = err as { status?: number; code?: string; message?: string };
return res.status(appErr.status ?? 500).json({
error: {
Expand Down