Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"jose": "^6.2.8",
"lucide-react": "^1.28.0",
"next": "16.3.0",
"@sentry/nextjs": "^8.20.0",
"next-themes": "^0.4.6",
"pg": "^8.22.0",
"react": "19.2.4",
Expand Down
33 changes: 33 additions & 0 deletions apps/web/sentry.client.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as Sentry from '@sentry/nextjs';

const COMMON_SENTRY_CONFIG = {
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,

// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,

// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,

// Scrub PII (like Stellar addresses which are pseudonymous but still user data)
beforeSend(event: Sentry.ErrorEvent) {
if (event.request && event.request.url) {
// Redact potential Stellar addresses from URLs or inputs (G[A-Z2-7]{55})
event.request.url = event.request.url.replace(/G[A-Z2-7]{55}/g, '[REDACTED-ADDRESS]');
}

// Also recursively scrub objects inside the event if needed
// But Sentry already strips most PII. We'll add custom logic here if we log full addresses in breadcrumbs.
if (event.breadcrumbs) {
event.breadcrumbs.forEach((breadcrumb: Sentry.Breadcrumb) => {
if (breadcrumb.message) {
breadcrumb.message = breadcrumb.message.replace(/G[A-Z2-7]{55}/g, '[REDACTED-ADDRESS]');
}
});
}

return event;
},
};

Sentry.init(COMMON_SENTRY_CONFIG);
26 changes: 26 additions & 0 deletions apps/web/sentry.edge.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as Sentry from '@sentry/nextjs';

Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0,
debug: false,
// Scrub PII (like Stellar addresses which are pseudonymous but still user data)
beforeSend(event: Sentry.ErrorEvent) {
if (event.request && event.request.url) {
// Redact potential Stellar addresses from URLs or inputs (G[A-Z2-7]{55})
event.request.url = event.request.url.replace(/G[A-Z2-7]{55}/g, '[REDACTED-ADDRESS]');
}

// Also recursively scrub objects inside the event if needed
// But Sentry already strips most PII. We'll add custom logic here if we log full addresses in breadcrumbs.
if (event.breadcrumbs) {
event.breadcrumbs.forEach((breadcrumb: Sentry.Breadcrumb) => {
if (breadcrumb.message) {
breadcrumb.message = breadcrumb.message.replace(/G[A-Z2-7]{55}/g, '[REDACTED-ADDRESS]');
}
});
}

return event;
},
});
26 changes: 26 additions & 0 deletions apps/web/sentry.server.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as Sentry from '@sentry/nextjs';

Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0,
debug: false,
// Scrub PII (like Stellar addresses which are pseudonymous but still user data)
beforeSend(event: Sentry.ErrorEvent) {
if (event.request && event.request.url) {
// Redact potential Stellar addresses from URLs or inputs (G[A-Z2-7]{55})
event.request.url = event.request.url.replace(/G[A-Z2-7]{55}/g, '[REDACTED-ADDRESS]');
}

// Also recursively scrub objects inside the event if needed
// But Sentry already strips most PII. We'll add custom logic here if we log full addresses in breadcrumbs.
if (event.breadcrumbs) {
event.breadcrumbs.forEach((breadcrumb: Sentry.Breadcrumb) => {
if (breadcrumb.message) {
breadcrumb.message = breadcrumb.message.replace(/G[A-Z2-7]{55}/g, '[REDACTED-ADDRESS]');
}
});
}

return event;
},
});
61 changes: 61 additions & 0 deletions apps/web/src/lib/log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { expect, test, describe, beforeEach, vi, afterEach } from 'vitest';
import { logger, redact } from './log';

describe('Structured logger redaction', () => {
const originalEnv = process.env;

beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
});

afterEach(() => {
process.env = originalEnv;
});

test('redacts specific keys from objects', () => {
const input = {
safe_key: 'hello',
DATABASE_URL: 'postgres://user:pass@host/db',
nested: {
cron_secret: 'super-secret',
other: 123,
},
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = redact(input) as any;
expect(result.DATABASE_URL).toBe('[REDACTED]');
expect(result.safe_key).toBe('hello');
expect(result.nested.cron_secret).toBe('[REDACTED]');
expect(result.nested.other).toBe(123);
});

test('redacts actual environment variable values when they appear in strings', () => {
process.env.DATABASE_URL = 'postgres://secret-db-url';

const input = {
message: 'Failed to connect to postgres://secret-db-url inside string',
};

const result = redact(input) as Record<string, unknown>;
expect(result.message).toBe('Failed to connect to [REDACTED] inside string');
});

test('logger outputs valid JSON', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

logger.info('Test message', { req_id: '123' });

expect(consoleSpy).toHaveBeenCalledOnce();
const output = consoleSpy.mock.calls[0][0];

const parsed = JSON.parse(output as string);
expect(parsed.level).toBe('info');
expect(parsed.message).toBe('Test message');
expect(parsed.req_id).toBe('123');
expect(parsed.timestamp).toBeDefined();

consoleSpy.mockRestore();
});
});
77 changes: 77 additions & 0 deletions apps/web/src/lib/log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
const SENSITIVE_KEYS = new Set([
'database_url',
'cron_secret',
'merchant_public_key',
'webhook_url',
'x-signature',
]);

const LOG_LEVELS: Record<string, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
};

function getLogLevel(): number {
const level = (process.env.LOG_LEVEL || 'info').toLowerCase();
return LOG_LEVELS[level] || LOG_LEVELS.info;
}

export function redact(obj: unknown): unknown {
if (obj === null || obj === undefined) return obj;

if (typeof obj === 'string') {
// Also redact string values if they look like known sensitive environment variables
const secrets = [
process.env.DATABASE_URL,
process.env.CRON_SECRET,
process.env.MERCHANT_PUBLIC_KEY,
].filter(Boolean) as string[];

let redactedString = obj;
for (const secret of secrets) {
if (secret && redactedString.includes(secret)) {
redactedString = redactedString.replaceAll(secret, '[REDACTED]');
}
}
return redactedString;
}

if (typeof obj !== 'object') return obj;

if (Array.isArray(obj)) {
return obj.map(redact);
}

const redactedObj: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (SENSITIVE_KEYS.has(key.toLowerCase())) {
redactedObj[key] = '[REDACTED]';
} else {
redactedObj[key] =
typeof value === 'object' || typeof value === 'string' ? redact(value) : value;
}
}
return redactedObj;
}

function logMessage(level: string, message: string, meta?: Record<string, unknown>) {
if (LOG_LEVELS[level] < getLogLevel()) return;

const entry = {
level,
message,
timestamp: new Date().toISOString(),
...((redact(meta || {}) as object) || {}),
};

console.log(JSON.stringify(entry));
}

export const logger = {
debug: (message: string, meta?: Record<string, unknown>) => logMessage('debug', message, meta),
info: (message: string, meta?: Record<string, unknown>) => logMessage('info', message, meta),
warn: (message: string, meta?: Record<string, unknown>) => logMessage('warn', message, meta),
error: (message: string, meta?: Record<string, unknown>) => logMessage('error', message, meta),
};
Loading
Loading