Skip to content

Instantly share code, notes, and snippets.

@TCotton
Last active March 25, 2026 14:21
Show Gist options
  • Select an option

  • Save TCotton/6325b9a7b3e2f4b2ae8f7a8463dc9546 to your computer and use it in GitHub Desktop.

Select an option

Save TCotton/6325b9a7b3e2f4b2ae8f7a8463dc9546 to your computer and use it in GitHub Desktop.
Logging for server/client React 19
// redactSensitiveData and LoggerPort interface is below the UnifiedLogger class
import { redactSensitiveData } from '@norberts-spark/shared'
import type { LoggerPort } from '@/application/ports/logger.port.js'
import { env } from '@/env/client.js'
/**
* Enumeration of all supported log levels in ascending severity order.
*
* The ordering `TRACE < DEBUG < INFO < WARN < ERROR` is enforced by the
* `LOG_LEVELS` array inside {@link UnifiedLogger}. Any level below the
* configured `minLevel` is silently discarded.
*/
const LogLevel = {
/** Finest-grained diagnostic information. Suppressed in production. */
TRACE: 'trace',
/** Diagnostic information useful during development. Suppressed in production. */
DEBUG: 'debug',
/** Normal operational events. Suppressed in production. */
INFO: 'info',
/** Unexpected situations that do not stop execution. Always emitted. */
WARN: 'warn',
/** Failures that require attention. Always emitted. */
ERROR: 'error',
} as const
/**
* Union of all valid log level strings derived from {@link LogLevel}.
* Equivalent to `'trace' | 'debug' | 'info' | 'warn' | 'error'`.
*/
type LogLevelType = (typeof LogLevel)[keyof typeof LogLevel]
/**
* Configuration options for the UnifiedLogger.
*/
export interface LoggerOptions {
/**
* The minimum log level to output. Messages below this level will be filtered out.
* Hierarchy: TRACE < DEBUG < INFO < WARN < ERROR
* @default 'debug'
*/
minLevel?: LogLevelType
/**
* An optional context label attached to every log entry as `loggerContext`.
* Useful for identifying the module, component, or service that emitted the log.
* @example
* ```ts
* createLogger({ prefix: 'AuthService' })
* createLogger({ prefix: 'Users:ApiRoute' })
* ```
*/
prefix?: string
/**
* An optional numeric log level stored for compatibility with external systems
* that use numeric levels (e.g. Pino's `30 = info`).
*
* This value is **not** emitted into {@link StructuredLogEntry} — it is only
* accessible via {@link UnifiedLogger.getLevel}.
*/
level?: number
}
/**
* Legacy log shape returned by the pre-structured `UnifiedLogger`.
*
* @deprecated Superseded by {@link StructuredLogEntry}, which provides a
* machine-readable, GDPR-safe structured format. Do not use in new code.
*/
export interface FormattedLogMessage {
/** ISO 8601 timestamp when the log message was created. */
timestamp: string
/** The prefix string for the logger instance, if configured. */
prefix: string
/** The log method/level in uppercase (e.g. `'DEBUG'`, `'INFO'`, `'WARN'`, `'ERROR'`). */
method: string
/** The human-readable log message. */
message: string
/** Optional numeric log level, included only if configured in {@link LoggerOptions.level}. */
level?: number
}
/**
* A structured log entry emitted by {@link UnifiedLogger}.
*
* Every entry is machine-readable and designed for ingestion by log
* aggregators (Datadog, CloudWatch, Loki, etc.). Fields are grouped into
* five categories:
*
* 1. **Core** — `level`, `timestamp`, `message` (always present)
* 2. **Service metadata** — `service`, `env`, `version` (injected automatically)
* 3. **Logger identity** — `loggerContext` (derived from the `prefix` option)
* 4. **Error details** — `err` with `name` and `stack`; `err.message` omitted for GDPR safety
* 5. **Extra fields** — any caller-supplied key/value pairs via the `context` argument
*
* Sensitive fields are redacted by `redactSensitiveData` from `@norberts-spark/shared`
* before the entry is forwarded to `console.*`.
*
* @example
* Server-side structured log (Server Action / API Route):
* ```json
* {
* "level": "error",
* "timestamp": "2026-03-06T12:00:00.000Z",
* "event": "server-action.backend-request.failed",
* "message": "Backend returned 502",
* "service": "norberts-spark-frontend",
* "env": "production",
* "version": "1.2.0",
* "loggerContext": "BackendRequest",
* "statusCode": 502,
* "endpoint": "/api/v1/ai/chats",
* "durationMs": 1200,
* "err": { "name": "Error" }
* }
* ```
*
* @example
* Client-side structured log (React hook / component):
* ```json
* {
* "level": "error",
* "timestamp": "2026-03-06T12:00:05.000Z",
* "event": "chat.transport.error",
* "message": "Chat transport error",
* "service": "norberts-spark-frontend",
* "env": "production",
* "loggerContext": "UseAIChat:Hook",
* "err": { "name": "Error" }
* }
* ```
*/
export interface StructuredLogEntry {
/** Log level string. One of `'trace'`, `'debug'`, `'info'`, `'warn'`, `'error'`. */
level: string
/** ISO 8601 timestamp produced by `new Date().toISOString()`. */
timestamp: string
/** Human-readable description of the event. Must not contain PII. */
message: string
/** Identifies the application in a multi-service log aggregator. */
service: string
/** Runtime environment (`'production'`, `'development'`, `'test'`, etc.). */
env: string
/** Application version string, used to correlate log spikes with deploys. */
version: string
/**
* Module or component that emitted the log, derived from the `prefix`
* option passed to {@link createLogger}.
*/
loggerContext?: string
/**
* Stable, dot-separated machine-readable event name for filtering and
* alerting in log aggregators (e.g. `'server-action.backend-request.failed'`).
*/
event?: string
/**
* Serialised error details.
* - `name` — always present (e.g. `'TypeError'`).
* - `message` — included outside production to aid debugging. Omitted in
* production because error messages may contain PII.
* - `stack` — included outside production with the full stack trace.
* - `cause` — included outside production when `error.cause` is set (Node.js
* 16.9+ wraps underlying errors here, e.g. the root cause of a fetch failure).
* - Additional own enumerable properties (e.g. `code`, `errno`, `statusCode`)
* are captured outside production so that network/HTTP error details are visible.
*/
err?: { name: string; message?: string; stack?: string; cause?: unknown; [key: string]: unknown }
/**
* Application-specific error code for programmatic error handling
* (e.g. `'UNAUTHORIZED'`, `'NETWORK_ERROR'`, `'VALIDATION_FAILED'`).
*/
errorCode?: string
/** Any additional caller-supplied structured fields merged in at call time. */
[key: string]: unknown
}
/**
* Zero-dependency, `console`-based structured logger for the Next.js frontend.
*
* Works identically on the server (Node.js / Edge Runtime) and in the browser.
* Each log method builds a {@link StructuredLogEntry}, runs it through
* `redactSensitiveData`, and delegates to the matching `console.*` method
* (`console.info`, `console.warn`, etc.).
*
* **Production filtering**: `trace`, `debug`, and `info` are no-ops when
* `process.env.NODE_ENV === 'production'`. Only `warn` and `error` emit in
* production.
*
* **Context propagation**: Use {@link child} to bind persistent fields (e.g.
* `requestId`) that are merged into every entry produced by the child logger.
*
* @example
* ```ts
* const logger = createLogger({ prefix: 'BackendRequest' })
* logger.info('Request completed', {
* event: 'server-action.backend-request.completed',
* endpoint: '/api/v1/ai/chats',
* statusCode: 200,
* durationMs: 42,
* })
*
* // With per-request context binding:
* const reqLogger = logger.child({ requestId: 'req-abc-123' })
* reqLogger.warn('Slow response', { event: 'server-action.backend-request.slow', durationMs: 8000 })
* ```
*/
export class UnifiedLogger implements LoggerPort {
/**
* Ordered array of level strings used to compare severity by index position.
* Index 0 = lowest (`trace`), index 4 = highest (`error`).
*/
private static readonly LOG_LEVELS = [
LogLevel.TRACE,
LogLevel.DEBUG,
LogLevel.INFO,
LogLevel.WARN,
LogLevel.ERROR,
]
/** Application identifier injected into every log entry as `service`. */
private static readonly SERVICE_NAME = env.NEXT_PUBLIC_SERVICE_NAME || 'norberts-spark-frontend'
/** Runtime environment injected into every log entry as `env`. */
private static readonly ENV = process.env.NODE_ENV || 'development'
/** Application version injected into every log entry as `version`. */
private static readonly VERSION = env.NEXT_PUBLIC_APP_VERSION || 'unknown'
/**
* Core fields set by {@link formatMessage} that must never be overwritten by
* caller-supplied `context` or `child()` bindings.
* Declared as a static `Set` to avoid per-call allocation on the hot logging path.
*/
private static readonly RESERVED_FIELDS = new Set([
'level',
'timestamp',
'message',
'service',
'env',
'version',
'loggerContext',
])
/**
* Keys silently dropped from `context` and `child()` bindings to prevent
* prototype-pollution attacks.
*/
private static readonly BLOCKED_FIELDS = new Set(['__proto__', 'constructor', 'prototype'])
/**
* Persistent key/value pairs bound via {@link child} that are shallow-merged
* into every log entry produced by this logger instance.
*/
private bindings?: Record<string, unknown>
/** The minimum severity threshold below which log calls are discarded. */
private minLevel: LogLevelType
/** Context label emitted as `loggerContext` in every entry. Empty string means omitted. */
private readonly prefix: string
/** Optional numeric level stored for external compatibility; not emitted in entries. */
private level?: number
/**
* Creates a new {@link UnifiedLogger} instance.
*
* Prefer the {@link createLogger} factory over calling `new UnifiedLogger()`
* directly — it is more concise and consistent across the codebase.
*
* @param options - Optional configuration. Defaults to `minLevel: 'debug'` with no prefix.
*/
constructor(options: LoggerOptions = {}) {
this.level = options.level
this.minLevel = options.minLevel || LogLevel.DEBUG
this.prefix = options.prefix || ''
}
/**
* Returns `true` when `logLevel` meets or exceeds `minLevel`.
*
* Comparison is by index position within the ordered {@link LOG_LEVELS}
* array (`trace = 0` … `error = 4`).
*
* @param logLevel - The severity of the message being evaluated.
* @returns `true` when the message should be emitted; `false` to suppress it.
*/
private shouldLog(logLevel: LogLevelType): boolean {
return (
UnifiedLogger.LOG_LEVELS.indexOf(logLevel) >= UnifiedLogger.LOG_LEVELS.indexOf(this.minLevel)
)
}
/**
* Builds a {@link StructuredLogEntry} for the given log level and message.
*
* Construction order:
* 1. Core fields (`level`, `timestamp`, `message`, `service`, `env`, `version`)
* 2. `loggerContext` from `this.prefix` (if set)
* 3. Bound fields from `this.bindings` (merged in by {@link child})
* 4. Per-call fields from `context`
*
* Reserved and prototype-polluting keys in steps 3–4 are silently dropped.
* The completed entry is passed through `redactSensitiveData` before being returned.
*
* @param logLevel - The severity level for this entry.
* @param message - The human-readable log message.
* @param context - Optional caller-supplied structured fields to merge into the entry.
* @returns A fully populated, redacted {@link StructuredLogEntry}.
*/
private formatMessage(
logLevel: LogLevelType,
message: string,
context?: Record<string, unknown>
): StructuredLogEntry {
const entry: StructuredLogEntry = {
level: logLevel,
timestamp: new Date().toISOString(),
message,
service: UnifiedLogger.SERVICE_NAME,
env: UnifiedLogger.ENV,
version: UnifiedLogger.VERSION,
}
if (this.prefix) {
entry.loggerContext = this.prefix
}
// Merge bound context from child() loggers
if (this.bindings) {
for (const [k, v] of Object.entries(this.bindings)) {
if (!UnifiedLogger.RESERVED_FIELDS.has(k) && !UnifiedLogger.BLOCKED_FIELDS.has(k)) {
Object.assign(entry, { [k]: v })
}
}
}
// Merge per-call fields
if (context) {
for (const [k, v] of Object.entries(context)) {
if (!UnifiedLogger.RESERVED_FIELDS.has(k) && !UnifiedLogger.BLOCKED_FIELDS.has(k)) {
Object.assign(entry, { [k]: v })
}
}
}
return redactSensitiveData(entry) as StructuredLogEntry
}
/**
* Converts an `Error` into a serialised `err` object for inclusion in a
* {@link StructuredLogEntry}.
*
* - `err.name` is always included (e.g. `'TypeError'`, `'RangeError'`).
* - `err.message` is included outside production even when it is an empty
* string, so that `Error()` with no message is still distinguishable from
* a missing message. Omitted in production because messages may contain PII.
* - `err.stack` is included only outside production with the full stack trace.
* - `err.cause` is included outside production when `error.cause` is set
* (Node.js 16.9+). If the cause is itself an `Error` it is recursively
* serialised; otherwise it is converted to a string. This surfaces the
* underlying network/system error that wrapping errors (e.g. `fetch failed`)
* hide behind a generic top-level message.
* - Any additional own enumerable properties (e.g. `code`, `errno`,
* `statusCode`, `syscall`) are captured outside production so that
* Node.js system and HTTP errors expose their full context.
*
* @param error - The `Error` instance to serialise.
* @returns A plain object containing `name`, and outside production, `message`,
* `stack`, `cause`, and any extra enumerable own properties.
*/
private serializeError(error: Error): {
name: string
message?: string
stack?: string
cause?: unknown
[key: string]: unknown
} {
const serialized: {
name: string
message?: string
stack?: string
cause?: unknown
[key: string]: unknown
} = { name: error.name }
if (UnifiedLogger.ENV !== 'production') {
// Use !== undefined so that an empty-string message is still surfaced
if (error.message !== undefined) {
serialized.message = error.message
}
if (error.stack) {
serialized.stack = error.stack
}
// Capture error.cause (Node.js 16.9+) — this is where fetch/undici wraps
// the real underlying network error (e.g. ECONNREFUSED, ECONNRESET)
const cause = (error as Error & { cause?: unknown }).cause
if (cause !== undefined) {
serialized.cause = cause instanceof Error ? this.serializeError(cause) : String(cause)
}
// Capture any additional own enumerable properties such as `code`, `errno`,
// `statusCode`, or `syscall` that are common on Node.js system/network errors
const STANDARD_KEYS = new Set(['name', 'message', 'stack', 'cause'])
const errorRecord = error as unknown as Record<string, unknown>
for (const key of Object.keys(error)) {
if (!STANDARD_KEYS.has(key)) {
Reflect.set(serialized, key, Reflect.get(errorRecord, key))
}
}
}
return serialized
}
/**
* Emits a `warn`-level structured log entry.
*
* Always emitted regardless of `NODE_ENV`. Use it for unexpected situations
* that do not stop execution but indicate something is wrong (e.g. a slow
* response, a retried request, a deprecated code path).
*
* @param message - Human-readable description of the warning. Must not contain PII.
* @param context - Optional structured fields merged into the entry
* (e.g. `{ event: 'middleware.rate-limit.exceeded', endpoint: '/api/v1/chat' }`).
*/
warn(message: string, context?: Record<string, unknown>): void {
if (this.shouldLog(LogLevel.WARN)) {
const entry = this.formatMessage(LogLevel.WARN, message, context)
console.warn(entry)
}
}
/**
* Emits an `error`-level structured log entry.
*
* Always emitted regardless of `NODE_ENV`. Use it for failures that require
* attention (e.g. a failed backend request, an uncaught exception).
*
* When an `Error` is provided it is serialised via {@link serializeError}:
* `err.name` is always present; outside production `err.message` and
* `err.stack` are also included. In production `err.message` is omitted
* because error messages may contain PII.
*
* @param message - Human-readable description of the failure. Must not contain PII.
* @param error - Optional `Error` instance to serialise into the `err` field.
* @param context - Optional structured fields merged into the entry
* (e.g. `{ event: 'server-action.backend-request.failed', statusCode: 502 }`).
*/
error(message: string, error?: Error, context?: Record<string, unknown>): void {
if (this.shouldLog(LogLevel.ERROR)) {
const errorContext = error ? { ...context, err: this.serializeError(error) } : context
const entry = this.formatMessage(LogLevel.ERROR, message, errorContext)
console.error(entry)
}
}
/**
* Emits an `info`-level structured log entry.
*
* Suppressed when `process.env.NODE_ENV === 'production'`. Use it for normal
* operational events that are valuable during development and staging (e.g. a
* successful backend request, a completed user action).
*
* @param message - Human-readable description of the event. Must not contain PII.
* @param context - Optional structured fields merged into the entry
* (e.g. `{ event: 'server-action.backend-request.completed', durationMs: 42 }`).
*/
info(message: string, context?: Record<string, unknown>): void {
if (this.shouldLog(LogLevel.INFO) && UnifiedLogger.ENV !== 'production') {
const entry = this.formatMessage(LogLevel.INFO, message, context)
console.info(entry)
}
}
/**
* Emits a `debug`-level structured log entry.
*
* Suppressed when `process.env.NODE_ENV === 'production'`. Use it for
* diagnostic information useful when tracing a specific flow during
* development but too noisy for production.
*
* @param message - Human-readable debug message. Must not contain PII.
* @param context - Optional structured fields merged into the entry.
*/
debug(message: string, context?: Record<string, unknown>): void {
if (this.shouldLog(LogLevel.DEBUG) && UnifiedLogger.ENV !== 'production') {
const entry = this.formatMessage(LogLevel.DEBUG, message, context)
console.debug(entry)
}
}
/**
* Emits a `trace`-level structured log entry.
*
* Suppressed when `process.env.NODE_ENV === 'production'`. Use it for the
* finest-grained diagnostic information (e.g. entering/leaving a function,
* loop iterations) where even `debug` would be too coarse.
*
* @param message - Human-readable trace message. Must not contain PII.
* @param context - Optional structured fields merged into the entry.
*/
trace(message: string, context?: Record<string, unknown>): void {
if (this.shouldLog(LogLevel.TRACE) && UnifiedLogger.ENV !== 'production') {
const entry = this.formatMessage(LogLevel.TRACE, message, context)
console.trace(entry)
}
}
/**
* Creates a new {@link UnifiedLogger} with the supplied `bindings` pre-merged
* into every log entry it produces.
*
* The child inherits `minLevel` and `prefix` from the parent. Parent bindings
* are shallow-merged with the new `bindings` (child wins on key conflicts).
* The parent logger is **not** mutated.
*
* Prototype-polluting keys (`__proto__`, `constructor`, `prototype`) in
* `bindings` are silently dropped when entries are built.
*
* @param bindings - Key/value pairs to pre-merge into every entry emitted by
* the child logger (e.g. `{ requestId: 'req-abc-123' }`).
* @returns A new `UnifiedLogger` instance with the bound context.
*
* @example
* ```ts
* const reqLogger = logger.child({ requestId: req.headers['x-request-id'] })
* reqLogger.info('Handling request', { event: 'api-route.users.fetched' })
* // → entry contains both requestId and event
* ```
*/
child(bindings: Record<string, unknown>): UnifiedLogger {
const childLogger = new UnifiedLogger({
minLevel: this.minLevel,
prefix: this.prefix,
level: this.level,
})
// Shallow-merge parent bindings with new bindings; child wins on key conflicts.
childLogger.bindings = { ...this.bindings, ...bindings }
return childLogger
}
/**
* Updates the minimum log level threshold at runtime.
*
* Any subsequent log call below the new `minLevel` will be silently
* discarded. The production guard is unaffected — `trace`, `debug`, and
* `info` remain no-ops in production regardless of `minLevel`.
*
* @param minLevel - The new minimum level
* (`'trace'` | `'debug'` | `'info'` | `'warn'` | `'error'`).
*
* @example
* ```ts
* logger.setMinLevel('warn') // silence debug and info going forward
* ```
*/
setMinLevel(minLevel: LogLevelType): void {
this.minLevel = minLevel
}
/**
* Returns the current minimum logging level threshold.
*
* @returns The current minimum level ('trace' | 'debug' | 'info' | 'warn' | 'error')
*
* @example
* ```typescript
* const currentMinLevel = logger.getMinLevel() // e.g., 'debug'
* ```
*/
getMinLevel(): LogLevelType {
return this.minLevel
}
/**
* Stores an optional numeric log level for compatibility with external
* systems that use numeric levels (e.g. Pino's `30 = info`).
*
* This value is **not** emitted into the {@link StructuredLogEntry} — the
* entry always uses the string `level` field. The numeric level is only
* accessible via {@link getLevel} and is intended for serialisation to
* external log consumers.
*
* @param level - The numeric log level to store (e.g. `30` for info in Pino).
*
* @example
* ```ts
* logger.setLevel(30)
* logger.getLevel() // → 30
* ```
*/
setLevel(level: number): void {
this.level = level
}
/**
* Returns the current numeric log level if set, otherwise undefined.
*
* @returns The numeric level or undefined if not configured
*
* @example
* ```typescript
* const level = logger.getLevel() // e.g., 30 or undefined
* ```
*/
getLevel(): number | undefined {
return this.level
}
}
/**
* Factory function to create a new UnifiedLogger instance.
* Provides a convenient way to instantiate loggers without using 'new'.
*
* @param options - Optional configuration for the logger
* @returns A new UnifiedLogger instance
*
* @example
* ```typescript
* const logger = createLogger({ minLevel: 'info', prefix: 'API' })
* logger.info('Request received')
* ```
*/
export function createLogger(options?: LoggerOptions): UnifiedLogger {
return new UnifiedLogger(options)
}
export interface LoggerPort {
trace(message: string, context?: Record<string, unknown>): void
info(message: string, context?: Record<string, unknown>): void
error(message: string, error?: Error, context?: Record<string, unknown>): void
warn(message: string, context?: Record<string, unknown>): void
debug(message: string, context?: Record<string, unknown>): void
/** Return a new logger with the given fields pre-merged into every log line. */
child(bindings: Record<string, unknown>): LoggerPort
}
/**
* List of sensitive field names that should be redacted in audit logs
* to comply with security and privacy requirements (GDPR, PCI-DSS, etc.).
*
* This list is also used by the Pino logger's `redact` option so that
* sensitive values are automatically censored in every structured log line.
*/
export const SENSITIVE_FIELDS = [
// Authentication & Authorization
'password',
'passwordHash',
'currentPassword',
'newPassword',
'oldPassword',
'confirmPassword',
'token',
'accessToken',
'refreshToken',
'resetToken',
'oldToken',
'newToken',
'apiKey',
'secret',
'privateKey',
'publicKey',
'jwt',
'sessionId',
'authToken',
// Financial Information
'creditCard',
'cardNumber',
'cvv',
'cvc',
'expiryDate',
'cardholderName',
'bankAccount',
'routingNumber',
'iban',
'swift',
// Personal Identifiable Information (PII)
'email',
'ip',
'ssn',
'socialSecurityNumber',
'taxId',
'nationalId',
'passport',
'driversLicense',
'dob',
'dateOfBirth',
// Healthcare
'medicalRecord',
'healthRecord',
'diagnosis',
'prescription',
// Biometric
'fingerprint',
'faceId',
'retinaScan',
'biometric',
] as const
const SENSITIVE_FIELDS_LOWER = new Set(SENSITIVE_FIELDS.map((field) => field.toLowerCase()))
/**
* Placeholder text for redacted sensitive fields
*/
const REDACTED_PLACEHOLDER = '[REDACTED]'
/**
* Keys that must never be written to a plain object to prevent prototype pollution
*/
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
/**
* Recursively redacts sensitive fields from an object
*
* This function deeply traverses nested objects and arrays to ensure
* sensitive data is removed at all levels before being stored in audit logs.
*
* @param data - The data object to redact (can be any type)
* @param depth - Current recursion depth (prevents infinite loops)
* @param maxDepth - Maximum recursion depth (default: 10)
* @returns A new object with sensitive fields redacted
*
* @example
* ```typescript
* const data = {
* email: 'user@example.com',
* password: 'secret123',
* profile: {
* name: 'John',
* ssn: '123-45-6789'
* }
* }
*
* const redacted = redactSensitiveData(data)
* // Result:
* // {
* // email: '[REDACTED]',
* // password: '[REDACTED]',
* // profile: {
* // name: 'John',
* // ssn: '[REDACTED]'
* // }
* // }
* ```
*/
export function redactSensitiveData(
data: unknown,
depth: number = 0,
maxDepth: number = 10
): unknown {
// Prevent infinite recursion
if (depth > maxDepth) {
return '[MAX_DEPTH_EXCEEDED]'
}
// Handle null and undefined
if (data == null) {
return data
}
// Handle primitive types
if (typeof data !== 'object') {
return data
}
// Handle arrays
if (Array.isArray(data)) {
return data.map((item) => redactSensitiveData(item, depth + 1, maxDepth))
}
// Handle Date objects
if (data instanceof Date) {
return data
}
// Handle regular objects
// Use a null-prototype object so that keys like __proto__ cannot mutate
// the prototype chain (defense-in-depth alongside the DANGEROUS_KEYS guard).
const redacted: Record<string, unknown> = Object.create(null) as Record<string, unknown>
for (const [key, value] of Object.entries(data)) {
// Skip keys that could lead to prototype pollution
if (DANGEROUS_KEYS.has(key)) {
continue
}
// Check if the field name matches any sensitive field (case-insensitive)
const keyLower = key.toLowerCase()
const isFieldSensitive = SENSITIVE_FIELDS_LOWER.has(keyLower)
if (isFieldSensitive) {
// Redact the entire field
Reflect.set(redacted, key, REDACTED_PLACEHOLDER)
} else if (typeof value === 'object' && value !== null) {
// Recursively redact nested objects
Reflect.set(redacted, key, redactSensitiveData(value, depth + 1, maxDepth))
} else {
// Keep non-sensitive primitive values
Reflect.set(redacted, key, value)
}
}
return redacted
}
/**
* Type guard to check if changes object needs redaction
*/
export function hasChangesObject(
entry: unknown
): entry is { changes: Record<string, unknown> | null } {
return (
typeof entry === 'object' &&
entry !== null &&
'changes' in entry &&
(entry.changes === null || typeof entry.changes === 'object')
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment