diff --git a/src/__tests__/debug-logger.test.ts b/src/__tests__/debug-logger.test.ts new file mode 100644 index 0000000..05fc7da --- /dev/null +++ b/src/__tests__/debug-logger.test.ts @@ -0,0 +1,382 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { DebugLogger } from '../utils/debug-logger.js'; +import type { DebugConfig } from '../types/index.js'; + +describe('DebugLogger', () => { + let mockLogger: ReturnType; + let consoleLogSpy: ReturnType; + + beforeEach(() => { + mockLogger = vi.fn(); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Constructor Configuration', () => { + it('should handle undefined config as none level', () => { + const logger = new DebugLogger(undefined); + expect(logger.getLevel()).toBe('none'); + expect(logger.isEnabled()).toBe(false); + }); + + it('should handle false config as none level', () => { + const logger = new DebugLogger(false); + expect(logger.getLevel()).toBe('none'); + expect(logger.isEnabled()).toBe(false); + }); + + it('should handle true config as basic level (backward compatibility)', () => { + const logger = new DebugLogger(true); + expect(logger.getLevel()).toBe('basic'); + expect(logger.isEnabled()).toBe(true); + }); + + it('should handle string debug level', () => { + const logger = new DebugLogger('verbose'); + expect(logger.getLevel()).toBe('verbose'); + expect(logger.isEnabled()).toBe(true); + }); + + it('should handle full DebugConfig object', () => { + const config: DebugConfig = { + level: 'transport', + timestamps: false, + logger: mockLogger, + }; + const logger = new DebugLogger(config); + expect(logger.getLevel()).toBe('transport'); + expect(logger.isEnabled()).toBe(true); + }); + + it('should use console.log by default', () => { + const logger = new DebugLogger('basic'); + logger.basic('Test message', { data: 'test' }); + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + it('should use custom logger when provided', () => { + const config: DebugConfig = { + level: 'basic', + logger: mockLogger, + }; + const logger = new DebugLogger(config); + logger.basic('Test message', { data: 'test' }); + expect(mockLogger).toHaveBeenCalled(); + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Debug Level Hierarchy', () => { + it('should not log when level is none', () => { + const logger = new DebugLogger('none'); + logger.basic('Basic message', {}); + logger.verbose('Verbose message', {}); + logger.transport('Transport message', {}); + logger.trace('Trace message', {}); + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); + + it('should log only basic when level is basic', () => { + const config: DebugConfig = { level: 'basic', logger: mockLogger }; + const logger = new DebugLogger(config); + + logger.basic('Basic message', {}); + expect(mockLogger).toHaveBeenCalledTimes(1); + + logger.verbose('Verbose message', {}); + logger.transport('Transport message', {}); + logger.trace('Trace message', {}); + expect(mockLogger).toHaveBeenCalledTimes(1); + }); + + it('should log basic and verbose when level is verbose', () => { + const config: DebugConfig = { level: 'verbose', logger: mockLogger }; + const logger = new DebugLogger(config); + + logger.basic('Basic message', {}); + logger.verbose('Verbose message', {}); + expect(mockLogger).toHaveBeenCalledTimes(2); + + logger.transport('Transport message', {}); + logger.trace('Trace message', {}); + expect(mockLogger).toHaveBeenCalledTimes(2); + }); + + it('should log basic, verbose, and transport when level is transport', () => { + const config: DebugConfig = { level: 'transport', logger: mockLogger }; + const logger = new DebugLogger(config); + + logger.basic('Basic message', {}); + logger.verbose('Verbose message', {}); + logger.transport('Transport message', {}); + expect(mockLogger).toHaveBeenCalledTimes(3); + + logger.trace('Trace message', {}); + expect(mockLogger).toHaveBeenCalledTimes(3); + }); + + it('should log everything when level is trace', () => { + const config: DebugConfig = { level: 'trace', logger: mockLogger }; + const logger = new DebugLogger(config); + + logger.basic('Basic message', {}); + logger.verbose('Verbose message', {}); + logger.transport('Transport message', {}); + logger.trace('Trace message', {}); + expect(mockLogger).toHaveBeenCalledTimes(4); + }); + }); + + describe('Data Sanitization', () => { + it('should sanitize wallet addresses', () => { + const config: DebugConfig = { level: 'basic', logger: mockLogger }; + const logger = new DebugLogger(config); + + logger.basic('Test', { + wallet: '0x1234567890abcdef1234567890abcdef12345678', + }); + + expect(mockLogger).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + wallet: '0x1234...5678', + }) + ); + }); + + it('should sanitize signatures', () => { + const config: DebugConfig = { level: 'basic', logger: mockLogger }; + const logger = new DebugLogger(config); + + const signature = '0x' + 'a'.repeat(130); + logger.basic('Test', { signature }); + + expect(mockLogger).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + signature: '0xaaaaaaaa...aaaaaaaa', + }) + ); + }); + + it('should redact sensitive fields', () => { + const config: DebugConfig = { level: 'basic', logger: mockLogger }; + const logger = new DebugLogger(config); + + logger.basic('Test', { + privateKey: 'secret-key', + secret: 'my-secret', + password: 'my-password', + publicData: 'visible', + }); + + expect(mockLogger).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + privateKey: '[REDACTED]', + secret: '[REDACTED]', + password: '[REDACTED]', + publicData: 'visible', + }) + ); + }); + + it('should truncate very long strings', () => { + const config: DebugConfig = { level: 'basic', logger: mockLogger }; + const logger = new DebugLogger(config); + + const longString = 'x'.repeat(2000); + logger.basic('Test', { data: longString }); + + expect(mockLogger).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + data: expect.stringMatching(/^x{500}.*truncated 1500 chars.*$/), + }) + ); + }); + + it('should sanitize nested objects', () => { + const config: DebugConfig = { level: 'basic', logger: mockLogger }; + const logger = new DebugLogger(config); + + logger.basic('Test', { + outer: { + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + inner: { + signature: '0x' + 'b'.repeat(130), + secret: 'hidden', + }, + }, + }); + + expect(mockLogger).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + outer: expect.objectContaining({ + walletAddress: '0x1234...5678', + inner: expect.objectContaining({ + signature: '0xbbbbbbbb...bbbbbbbb', + secret: '[REDACTED]', + }), + }), + }) + ); + }); + + it('should sanitize arrays', () => { + const config: DebugConfig = { level: 'basic', logger: mockLogger }; + const logger = new DebugLogger(config); + + logger.basic('Test', { + wallets: [ + '0x1234567890abcdef1234567890abcdef12345678', + '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + ], + }); + + expect(mockLogger).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + wallets: ['0x1234...5678', '0xabcd...abcd'], + }) + ); + }); + }); + + describe('Timestamps', () => { + it('should include timestamps by default', () => { + const config: DebugConfig = { level: 'basic', logger: mockLogger }; + const logger = new DebugLogger(config); + + logger.basic('Test message', {}); + + expect(mockLogger).toHaveBeenCalledWith( + expect.stringMatching(/^\[\d{4}-\d{2}-\d{2}T.*\] Test message$/), + undefined + ); + }); + + it('should exclude timestamps when disabled', () => { + const config: DebugConfig = { + level: 'basic', + timestamps: false, + logger: mockLogger, + }; + const logger = new DebugLogger(config); + + logger.basic('Test message', {}); + + expect(mockLogger).toHaveBeenCalledWith('Test message', undefined); + }); + }); + + describe('Error Logging', () => { + it('should log errors when level is not none', () => { + const config: DebugConfig = { level: 'basic', logger: mockLogger }; + const logger = new DebugLogger(config); + + const error = new Error('Test error'); + logger.error('Error occurred', error, { extra: 'data' }); + + expect(mockLogger).toHaveBeenCalledWith( + expect.stringContaining('[ERROR] Error occurred'), + expect.objectContaining({ + extra: 'data', + error: expect.objectContaining({ + message: 'Test error', + name: 'Error', + }), + }) + ); + }); + + it('should not log errors when level is none', () => { + const config: DebugConfig = { level: 'none', logger: mockLogger }; + const logger = new DebugLogger(config); + + const error = new Error('Test error'); + logger.error('Error occurred', error, {}); + + expect(mockLogger).not.toHaveBeenCalled(); + }); + + it('should include stack trace only at trace level', () => { + const config: DebugConfig = { level: 'trace', logger: mockLogger }; + const logger = new DebugLogger(config); + + const error = new Error('Test error'); + logger.error('Error occurred', error); + + expect(mockLogger).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + error: expect.objectContaining({ + stack: expect.stringContaining('Error: Test error'), + }), + }) + ); + }); + + it('should not include stack trace at lower levels', () => { + const config: DebugConfig = { level: 'basic', logger: mockLogger }; + const logger = new DebugLogger(config); + + const error = new Error('Test error'); + logger.error('Error occurred', error); + + expect(mockLogger).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + error: expect.objectContaining({ + message: 'Test error', + stack: undefined, + }), + }) + ); + }); + }); + + describe('Edge Cases', () => { + it('should handle null and undefined values', () => { + const config: DebugConfig = { level: 'basic', logger: mockLogger }; + const logger = new DebugLogger(config); + + logger.basic('Test', { + nullValue: null, + undefinedValue: undefined, + normalValue: 'test', + }); + + expect(mockLogger).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + nullValue: null, + undefinedValue: undefined, + normalValue: 'test', + }) + ); + }); + + it('should handle non-string wallet addresses gracefully', () => { + const config: DebugConfig = { level: 'basic', logger: mockLogger }; + const logger = new DebugLogger(config); + + logger.basic('Test', { + wallet: 12345, + validWallet: '0x1234567890abcdef1234567890abcdef12345678', + }); + + expect(mockLogger).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + wallet: 12345, + validWallet: '0x1234...5678', + }) + ); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/radius-mcp-sdk.test.ts b/src/__tests__/radius-mcp-sdk.test.ts index a1349bd..d62fe6f 100644 --- a/src/__tests__/radius-mcp-sdk.test.ts +++ b/src/__tests__/radius-mcp-sdk.test.ts @@ -646,8 +646,9 @@ describe('Radius MCP SDK', () => { }); it('should include debug info when debug is enabled', async () => { + // Spy must be created before SDK instantiation + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const debugSdk = new RadiusMcpSdk({ ...config, debug: true }); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const walletAddress = validProof.challenge.message.walletAddress; mockRecoverTypedDataAddress.mockResolvedValue(walletAddress); @@ -666,12 +667,15 @@ describe('Radius MCP SDK', () => { // Should log both token check failure and unexpected error expect(consoleSpy).toHaveBeenCalled(); - // Check for the unexpected error log - const unexpectedErrorCall = consoleSpy.mock.calls.find( - (call) => call[0] === '[Radius SDK] Unexpected error:' + // Check for error log (debug logger adds [ERROR] prefix) + const errorCall = consoleSpy.mock.calls.find( + (call) => typeof call[0] === 'string' && call[0].includes('[ERROR]') ); - expect(unexpectedErrorCall).toBeDefined(); - expect(unexpectedErrorCall?.[1]).toMatchObject({ + expect(errorCall).toBeDefined(); + + // Check that error context is included + const errorContext = errorCall?.[1] as Record; + expect(errorContext?.error).toMatchObject({ message: 'ETIMEDOUT', name: 'Error', }); diff --git a/src/radius-mcp-sdk.ts b/src/radius-mcp-sdk.ts index c141581..cb70982 100644 --- a/src/radius-mcp-sdk.ts +++ b/src/radius-mcp-sdk.ts @@ -12,6 +12,7 @@ import { } from 'viem'; import type { CacheConfig, RadiusConfig, MCPHandler, MCPRequest, MCPResponse, EVMAuthErrorResponse, EVMAuthProof, ProofErrorCode } from './types/index.js'; import { RadiusError } from './types/errors.js'; +import { DebugLogger } from './utils/debug-logger.js'; const ERC1155_ABI = [ { @@ -89,6 +90,7 @@ export class RadiusMcpSdk { private contract: GetContractReturnType; private cache: TokenCache; private config: Required; + private debugLogger: DebugLogger; constructor(config: RadiusConfig) { const configWithDefaults = { @@ -137,39 +139,73 @@ export class RadiusMcpSdk { }); this.cache = new TokenCache(this.config.cache); + + // Initialize debug logger + this.debugLogger = new DebugLogger(this.config.debug); // Warn if debug mode is enabled - if (config.debug) { + if (this.debugLogger.isEnabled()) { console.warn( '\n⚠️ WARNING: Debug mode is enabled in Radius MCP SDK\n' + + ` Debug level: ${this.debugLogger.getLevel()}\n` + ' Debug mode may expose sensitive information in logs.\n' + ' DO NOT use debug mode in production environments!\n' ); } } + /** + * Helper to log debug messages with backward compatibility + */ + private log(message: string, context: Record): void { + // Map old debug calls to appropriate levels based on step + const step = context.step as string; + + if (step?.includes('error') || step?.includes('failed')) { + this.debugLogger.verbose(message, context); + } else if (step?.includes('start') || step?.includes('complete')) { + this.debugLogger.basic(message, context); + } else if (step?.includes('extraction') || step?.includes('verification')) { + this.debugLogger.verbose(message, context); + } else { + this.debugLogger.verbose(message, context); + } + } + protect(tokenId: number | number[], handler: MCPHandler): MCPHandler { const tokenIds = Array.isArray(tokenId) ? tokenId : [tokenId]; return async (request: MCPRequest, extra?: unknown): Promise => { const authFlowId = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; - if (this.config.debug) { - console.log('[Radius] Auth flow started', { - step: 'auth_flow_start', - authFlowId, - requiredTokens: tokenIds, - hasProof: !!(request?.params?.arguments as Record)?.__evmauth, - hint: '__evmauth parameter is accepted on ALL protected tools regardless of schema', - }); - } + // Basic: Auth flow start + this.debugLogger.basic('[Radius] Auth flow started', { + step: 'auth_flow_start', + authFlowId, + requiredTokens: tokenIds, + hasProof: !!(request?.params?.arguments as Record)?.__evmauth, + }); + + // Verbose: Additional hints + this.debugLogger.verbose('[Radius] Auth flow hints', { + authFlowId, + hint: '__evmauth parameter is accepted on ALL protected tools regardless of schema', + }); + + // Transport: Raw request data (NEW enhanced logging) + this.debugLogger.transport('[Radius] Raw request', { + step: 'request_raw', + authFlowId, + method: (request as Record).method, + params: request?.params, + }); const params = request?.params as { name?: string; arguments?: Record }; const toolName = params?.name || 'unknown_tool'; try { if (this.config.debug) { - console.log('[Radius] Full request structure', { + this.log('[Radius] Full request structure', { step: 'request_inspection', authFlowId, method: request?.method, @@ -191,7 +227,7 @@ export class RadiusMcpSdk { const proof = this.extractProof(request); if (!proof) { if (this.config.debug) { - console.log('[Radius] Auth flow', { + this.log('[Radius] Auth flow', { step: 'proof_missing', authFlowId, success: false, @@ -202,7 +238,7 @@ export class RadiusMcpSdk { } if (this.config.debug) { - console.log('[Radius] Auth flow', { + this.log('[Radius] Auth flow', { step: 'proof_verification_start', authFlowId, proofPurpose: proof.challenge.message.purpose, @@ -217,7 +253,7 @@ export class RadiusMcpSdk { const walletAddress = await this.verifyProof(proof, toolName, argsForVerification); if (this.config.debug) { - console.log('[Radius] Auth flow', { + this.log('[Radius] Auth flow', { step: 'proof_verification_success', authFlowId, wallet: walletAddress, @@ -226,7 +262,7 @@ export class RadiusMcpSdk { } if (this.config.debug) { - console.log('[Radius] Auth flow', { + this.log('[Radius] Auth flow', { step: 'token_check_start', authFlowId, wallet: walletAddress, @@ -238,7 +274,7 @@ export class RadiusMcpSdk { if (!hasAccess) { if (this.config.debug) { - console.log('[Radius] Auth flow', { + this.log('[Radius] Auth flow', { step: 'token_check_failed', authFlowId, wallet: walletAddress, @@ -251,7 +287,7 @@ export class RadiusMcpSdk { } if (this.config.debug) { - console.log('[Radius] Auth flow', { + this.log('[Radius] Auth flow', { step: 'auth_flow_complete', authFlowId, wallet: walletAddress, @@ -264,7 +300,7 @@ export class RadiusMcpSdk { return await handler(cleanRequest, extra); } catch (error) { if (this.config.debug) { - console.log('[Radius] Auth flow', { + this.log('[Radius] Auth flow', { step: 'auth_flow_error', authFlowId, success: false, @@ -288,7 +324,7 @@ export class RadiusMcpSdk { try { auth = JSON.parse(auth); if (this.config.debug) { - console.log('[Radius] Parsed stringified proof', { + this.log('[Radius] Parsed stringified proof', { step: 'proof_extraction', wasStringified: true, success: true, @@ -296,7 +332,7 @@ export class RadiusMcpSdk { } } catch (error) { if (this.config.debug) { - console.log('[Radius] Failed to parse stringified proof', { + this.log('[Radius] Failed to parse stringified proof', { step: 'proof_extraction', error: (error as Error).message, success: false, @@ -378,7 +414,7 @@ export class RadiusMcpSdk { const expiresAt = parseInt(message.expiresAt); if (Date.now() > expiresAt) { if (this.config.debug) { - console.log('[Radius] Proof verification failed', { + this.log('[Radius] Proof verification failed', { step: 'proof_verification', reason: 'expired', expiresAt: new Date(expiresAt).toISOString(), @@ -391,7 +427,7 @@ export class RadiusMcpSdk { if (domain.name !== 'EVMAuth' || domain.version !== '1') { if (this.config.debug) { - console.log('[Radius] Proof verification failed', { + this.log('[Radius] Proof verification failed', { step: 'proof_verification', reason: 'invalid_domain', expected: { name: 'EVMAuth', version: '1' }, @@ -404,7 +440,7 @@ export class RadiusMcpSdk { if (domain.chainId !== this.config.chainId) { if (this.config.debug) { - console.log('[Radius] Proof verification failed', { + this.log('[Radius] Proof verification failed', { step: 'proof_verification', reason: 'chain_mismatch', expectedChainId: this.config.chainId, @@ -420,7 +456,7 @@ export class RadiusMcpSdk { if (domain.verifyingContract.toLowerCase() !== this.config.contractAddress.toLowerCase()) { if (this.config.debug) { - console.log('[Radius] Proof verification failed', { + this.log('[Radius] Proof verification failed', { step: 'proof_verification', reason: 'contract_mismatch', expectedContract: this.config.contractAddress, @@ -439,7 +475,7 @@ export class RadiusMcpSdk { if (resourceToolName !== toolName) { if (this.config.debug) { - console.log('[Radius] Proof verification failed', { + this.log('[Radius] Proof verification failed', { step: 'proof_verification', reason: 'tool_mismatch', expectedTool: toolName, @@ -476,7 +512,7 @@ export class RadiusMcpSdk { if (expectedHash !== message.requestHash) { if (this.config.debug) { - console.log('[Radius] Proof verification failed', { + this.log('[Radius] Proof verification failed', { step: 'proof_verification', reason: 'request_hash_mismatch', expectedHash, @@ -508,7 +544,7 @@ export class RadiusMcpSdk { if (!constantTimeEqual(signerLower, expectedLower)) { if (this.config.debug) { - console.log('[Radius] Proof verification failed', { + this.log('[Radius] Proof verification failed', { step: 'proof_verification', reason: 'signer_mismatch', expectedWallet: message.walletAddress, @@ -520,7 +556,7 @@ export class RadiusMcpSdk { } if (this.config.debug) { - console.log('[Radius] Proof verification succeeded', { + this.log('[Radius] Proof verification succeeded', { step: 'proof_verification', wallet: signerAddress, chainId: domain.chainId, @@ -601,7 +637,7 @@ export class RadiusMcpSdk { } if (this.config.debug) { - console.log('[Radius] Token ownership check', { + this.log('[Radius] Token ownership check', { step: 'token_check', wallet, tokenOwnership, @@ -615,7 +651,7 @@ export class RadiusMcpSdk { } } catch (error) { if (this.config.debug) { - console.error('[Radius SDK] Token check failed', { + this.debugLogger.error('[Radius SDK] Token check failed', error as Error, { step: 'token_check', wallet, tokenIds, @@ -766,7 +802,7 @@ export class RadiusMcpSdk { private handleError(error: Error, tokenIds?: number[], toolName?: string): EVMAuthErrorResponse { if (this.config.debug) { - console.error('[Radius SDK] Unexpected error:', { + this.debugLogger.error('[Radius SDK] Unexpected error', error as Error, { message: error.message, stack: error.stack, name: error.name, diff --git a/src/types/index.ts b/src/types/index.ts index 60bbc63..214f053 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,37 @@ export * from './errors.js'; +/** + * Debug logging levels for the Radius SDK + * - 'none': No debug logging + * - 'basic': Basic auth flow start/end (default for debug: true) + * - 'verbose': Detailed step-by-step flow + * - 'transport': Includes raw request/response data (sanitized) + * - 'trace': Everything including internal state changes + */ +export type DebugLevel = 'none' | 'basic' | 'verbose' | 'transport' | 'trace'; + +/** + * Debug configuration for granular logging control + */ +export interface DebugConfig { + /** + * Debug logging level + * @default 'none' + */ + level: DebugLevel; + + /** + * Include timestamps in debug output + * @default true + */ + timestamps?: boolean; + + /** + * Custom logger function (defaults to console.log) + */ + logger?: (message: string, context?: Record) => void; +} + export interface RadiusConfig { /** * ERC-1155 contract address for token ownership verification @@ -28,10 +60,13 @@ export interface RadiusConfig { cache?: CacheConfig; /** - * Enable debug logging + * Debug logging configuration + * - boolean: true maps to 'basic' level, false to 'none' + * - DebugLevel: string literal for specific level + * - DebugConfig: object for full control * @default false */ - debug?: boolean; + debug?: boolean | DebugLevel | DebugConfig; } export interface CacheConfig { diff --git a/src/utils/debug-logger.ts b/src/utils/debug-logger.ts new file mode 100644 index 0000000..b023d37 --- /dev/null +++ b/src/utils/debug-logger.ts @@ -0,0 +1,190 @@ +import type { DebugLevel, DebugConfig } from '../types/index.js'; + +/** + * Debug logger with configurable levels and data sanitization + */ +export class DebugLogger { + private readonly level: DebugLevel; + private readonly timestamps: boolean; + private readonly logger: (message: string, context?: Record) => void; + + /** + * Numeric level values for comparison + */ + private static readonly LEVELS: Readonly> = { + none: 0, + basic: 1, + verbose: 2, + transport: 3, + trace: 4, + } as const; + + constructor(config: boolean | DebugLevel | DebugConfig | undefined) { + // Handle backward compatibility and different config formats + if (config === undefined || config === false) { + this.level = 'none'; + this.timestamps = true; + this.logger = console.log; + } else if (config === true) { + // Backward compatibility: true maps to 'basic' + this.level = 'basic'; + this.timestamps = true; + this.logger = console.log; + } else if (typeof config === 'string') { + this.level = config; + this.timestamps = true; + this.logger = console.log; + } else { + this.level = config.level; + this.timestamps = config.timestamps !== false; + this.logger = config.logger || console.log; + } + } + + /** + * Check if a debug level is enabled + */ + private isLevelEnabled(requiredLevel: DebugLevel): boolean { + return DebugLogger.LEVELS[this.level] >= DebugLogger.LEVELS[requiredLevel]; + } + + /** + * Sanitize sensitive data in debug output + */ + private sanitize(data: unknown): unknown { + if (data === null || data === undefined) return data; + + if (typeof data === 'string') { + // Sanitize wallet addresses (0x followed by 40 hex chars) + if (/^0x[0-9a-f]{40}$/i.test(data)) { + return `${data.slice(0, 6)}...${data.slice(-4)}`; + } + + // Sanitize signatures (0x followed by 130 hex chars) + if (/^0x[0-9a-f]{130}$/i.test(data)) { + return `${data.slice(0, 10)}...${data.slice(-8)}`; + } + + // Truncate very long strings + if (data.length > 1000) { + return `${data.slice(0, 500)}...[truncated ${data.length - 500} chars]`; + } + + return data; + } + + if (Array.isArray(data)) { + return data.map(item => this.sanitize(item)); + } + + if (typeof data === 'object') { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(data as Record)) { + // Special handling for known sensitive fields + if (key === 'signature' && typeof value === 'string') { + sanitized[key] = this.sanitize(value); + } else if (key === 'walletAddress' && typeof value === 'string') { + sanitized[key] = this.sanitize(value); + } else if (key === 'privateKey' || key === 'secret' || key === 'password') { + sanitized[key] = '[REDACTED]'; + } else { + sanitized[key] = this.sanitize(value); + } + } + return sanitized; + } + + return data; + } + + /** + * Format log message with optional timestamp + */ + private formatMessage(message: string): string { + if (this.timestamps) { + return `[${new Date().toISOString()}] ${message}`; + } + return message; + } + + /** + * Log at basic level (auth flow start/end) + */ + public basic(message: string, context?: Record): void { + if (!this.isLevelEnabled('basic')) return; + + const sanitizedContext = context && Object.keys(context).length > 0 + ? this.sanitize(context) as Record + : undefined; + this.logger(this.formatMessage(message), sanitizedContext); + } + + /** + * Log at verbose level (detailed step-by-step) + */ + public verbose(message: string, context?: Record): void { + if (!this.isLevelEnabled('verbose')) return; + + const sanitizedContext = context && Object.keys(context).length > 0 + ? this.sanitize(context) as Record + : undefined; + this.logger(this.formatMessage(message), sanitizedContext); + } + + /** + * Log at transport level (raw request/response) + */ + public transport(message: string, context?: Record): void { + if (!this.isLevelEnabled('transport')) return; + + const sanitizedContext = context && Object.keys(context).length > 0 + ? this.sanitize(context) as Record + : undefined; + this.logger(this.formatMessage(message), sanitizedContext); + } + + /** + * Log at trace level (everything including internal state) + */ + public trace(message: string, context?: Record): void { + if (!this.isLevelEnabled('trace')) return; + + const sanitizedContext = context && Object.keys(context).length > 0 + ? this.sanitize(context) as Record + : undefined; + this.logger(this.formatMessage(message), sanitizedContext); + } + + /** + * Log error with context (always logged unless level is 'none') + */ + public error(message: string, error: Error, context?: Record): void { + if (this.level === 'none') return; + + const errorContext = { + ...context, + error: { + message: error.message, + name: error.name, + stack: this.level === 'trace' ? error.stack : undefined, + }, + }; + + const sanitizedContext = this.sanitize(errorContext) as Record; + this.logger(this.formatMessage(`[ERROR] ${message}`), sanitizedContext); + } + + /** + * Get the current debug level + */ + public getLevel(): DebugLevel { + return this.level; + } + + /** + * Check if debug logging is enabled at any level + */ + public isEnabled(): boolean { + return this.level !== 'none'; + } +} \ No newline at end of file