import { Injectable, Logger } from "@nestjs/common"; import { InjectModel } from "@nestjs/mongoose"; import { isAxiosError, type AxiosResponse } from "axios"; import { randomBytes } from "node:crypto"; import { Model, Types } from "mongoose"; import { FanavaranAuditLog, FanavaranAuditLogDocument, FanavaranAuditStatus, FanavaranAuditStep, } from "./schema/fanavaran-audit-log.schema"; import type { FanavaranAuditSession } from "./fanavaran-audit.types"; export interface RecordFanavaranAuditStepInput { session: FanavaranAuditSession; step: FanavaranAuditStep; status: FanavaranAuditStatus; requestUrl?: string; requestMethod?: string; httpStatus?: number; requestHeaders?: Record; requestBody?: unknown; requestMeta?: Record; responseHeaders?: Record; responseBody?: unknown; responseMeta?: Record; errorMessage?: string; errorDetails?: Record; durationMs?: number; } export interface FanavaranHttpExchange { httpStatus?: number; requestHeaders?: Record; responseHeaders?: Record; responseBody?: unknown; } @Injectable() export class FanavaranAuditService { private readonly logger = new Logger(FanavaranAuditService.name); /** Max serialized chars kept for request/response bodies in audit docs. */ static readonly BODY_MAX_CHARS = 80_000; private static readonly SENSITIVE_HEADER_KEYS = new Set([ "password", "secret", "authorization", "authenticationtoken", "apptoken", "app-token", "x-api-key", "cookie", "set-cookie", ]); private static readonly SENSITIVE_BODY_KEYS = new Set([ "password", "secret", "authenticationtoken", "apptoken", "token", "files", ]); constructor( @InjectModel(FanavaranAuditLog.name) private readonly auditModel: Model, ) {} generateTrackingCode(): string { const date = new Date().toISOString().slice(0, 10).replace(/-/g, ""); const suffix = randomBytes(3).toString("hex").toUpperCase(); return `FNV-${date}-${suffix}`; } maskNationalCode(nationalCode: string): string { const trimmed = nationalCode.trim(); if (trimmed.length <= 4) { return "****"; } return `${trimmed.slice(0, 3)}****${trimmed.slice(-2)}`; } maskToken(value: string): string { const trimmed = value.trim(); if (trimmed.length <= 8) { return "****"; } return `${trimmed.slice(0, 4)}…${trimmed.slice(-4)} (len=${trimmed.length})`; } sanitizeHeaders( headers?: Record | null, ): Record | undefined { if (!headers || typeof headers !== "object") { return undefined; } const out: Record = {}; for (const [rawKey, rawValue] of Object.entries(headers)) { if (rawValue === undefined) continue; const key = String(rawKey); const lower = key.toLowerCase(); if (FanavaranAuditService.SENSITIVE_HEADER_KEYS.has(lower)) { out[key] = typeof rawValue === "string" ? this.maskToken(rawValue) : "***"; continue; } if ( typeof rawValue === "string" || typeof rawValue === "number" || typeof rawValue === "boolean" || rawValue === null ) { out[key] = rawValue; } else { out[key] = String(rawValue); } } return out; } sanitizeBody(body: unknown): unknown { if (body === undefined) { return undefined; } const masked = this.maskSensitiveDeep(body); try { const serialized = JSON.stringify(masked); if (serialized.length <= FanavaranAuditService.BODY_MAX_CHARS) { return masked; } return { _truncated: true, maxChars: FanavaranAuditService.BODY_MAX_CHARS, preview: serialized.slice(0, FanavaranAuditService.BODY_MAX_CHARS), }; } catch { const asString = String(masked); return asString.length <= FanavaranAuditService.BODY_MAX_CHARS ? asString : { _truncated: true, preview: asString.slice(0, FanavaranAuditService.BODY_MAX_CHARS), }; } } /** * Extract request/response headers + body from an Axios response or Axios * error (uses `config.headers` when present). Values are returned raw; * `recordStep` applies sanitisation before persistence. */ captureAxiosExchange( source: unknown, overrideRequestHeaders?: Record, ): FanavaranHttpExchange { const toPlainHeaders = ( headers?: Record | null, ): Record | undefined => { if (!headers || typeof headers !== "object") return undefined; const out: Record = {}; for (const [key, value] of Object.entries(headers)) { if (value === undefined) continue; out[key] = typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null ? value : String(value); } return out; }; if (isAxiosError(source)) { return { httpStatus: source.response?.status, requestHeaders: toPlainHeaders( overrideRequestHeaders ?? (source.config?.headers as Record | undefined), ), responseHeaders: toPlainHeaders( source.response?.headers as Record | undefined, ), responseBody: source.response?.data, }; } const response = source as AxiosResponse | null; if ( response && typeof response === "object" && "status" in response && "headers" in response ) { return { httpStatus: response.status, requestHeaders: toPlainHeaders( overrideRequestHeaders ?? (response.config?.headers as Record | undefined), ), responseHeaders: toPlainHeaders( response.headers as Record | undefined, ), responseBody: response.data, }; } return { requestHeaders: toPlainHeaders(overrideRequestHeaders), }; } sanitizeErrorDetails(error: unknown): Record { if (isAxiosError(error)) { const data = error.response?.data; return { type: "axios", status: error.response?.status, statusText: error.response?.statusText, data: this.sanitizeBody( typeof data === "object" && data !== null ? data : typeof data === "string" ? data.slice(0, 2000) : data, ), }; } if (error instanceof Error) { return { type: "error", name: error.name, message: error.message }; } return { type: "unknown", value: String(error) }; } extractErrorMessage(error: unknown): string { if (isAxiosError(error)) { const data = error.response?.data as | { Message?: string; message?: string } | string | undefined; if (typeof data === "string") { return data; } return ( data?.Message || data?.message || error.message || "Fanavaran request failed" ); } if (error instanceof Error) { return error.message; } return "Fanavaran request failed"; } formatErrorWithTrackingCode(message: string, trackingCode?: string): string { if (!trackingCode) { return message; } return `${message} (trackingCode: ${trackingCode})`; } async recordStep(input: RecordFanavaranAuditStepInput): Promise { try { await this.auditModel.create({ trackingCode: input.session.trackingCode, step: input.step, status: input.status, clientKey: input.session.clientKey, source: input.session.source, ...(input.session.claimCaseId ? { claimCaseId: new Types.ObjectId(input.session.claimCaseId) } : {}), ...(input.session.claimRequestId ? { claimRequestId: new Types.ObjectId(input.session.claimRequestId) } : {}), requestUrl: input.requestUrl, requestMethod: input.requestMethod, httpStatus: input.httpStatus, requestHeaders: this.sanitizeHeaders(input.requestHeaders), requestBody: this.sanitizeBody(input.requestBody), requestMeta: input.requestMeta, responseHeaders: this.sanitizeHeaders(input.responseHeaders), responseBody: this.sanitizeBody(input.responseBody), responseMeta: input.responseMeta, errorMessage: input.errorMessage, errorDetails: input.errorDetails, durationMs: input.durationMs, }); } catch (error) { this.logger.error( `Failed to persist Fanavaran audit step ${input.step} (${input.status})`, error, ); } } async findByTrackingCode(trackingCode: string) { return this.auditModel .find({ trackingCode }) .sort({ createdAt: 1 }) .lean() .exec(); } private maskSensitiveDeep(value: unknown, depth = 0): unknown { if (depth > 8) { return "[max-depth]"; } if (value === null || value === undefined) { return value; } if (Array.isArray(value)) { return value.map((item) => this.maskSensitiveDeep(item, depth + 1)); } if (typeof value === "object") { const out: Record = {}; for (const [key, child] of Object.entries( value as Record, )) { if (FanavaranAuditService.SENSITIVE_BODY_KEYS.has(key.toLowerCase())) { out[key] = typeof child === "string" ? this.maskToken(child) : "[redacted]"; continue; } out[key] = this.maskSensitiveDeep(child, depth + 1); } return out; } return value; } }