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
4 changes: 2 additions & 2 deletions app/error.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect } from "react";
import { captureError } from "@/lib/observability/report";

export default function ErrorBoundary({
error,
Expand All @@ -10,8 +11,7 @@ export default function ErrorBoundary({
reset: () => void;
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error("ErrorBoundary caught:", error);
captureError(error, { boundary: "app/error", digest: error.digest });
}, [error]);

return (
Expand Down
4 changes: 2 additions & 2 deletions app/global-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect } from "react";
import "./globals.css";
import { captureError } from "@/lib/observability/report";

export default function GlobalError({
error,
Expand All @@ -11,8 +12,7 @@ export default function GlobalError({
reset: () => void;
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error("GlobalError caught:", error);
captureError(error, { boundary: "app/global-error", digest: error.digest });
}, [error]);

return (
Expand Down
31 changes: 31 additions & 0 deletions lib/observability/report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { scrubErrorPayload, type ScrubbedError } from "./scrub";

/**
* Single entry point for reporting a client/server error (issue #76).
*
* Everything routes through {@link scrubErrorPayload} first, so wallet
* addresses and request data can never reach the tracker. The actual
* transport (Sentry with build-time source maps, release = commit SHA,
* rate-based alerting) is layered on here without any call site changing.
*/

export interface ReportContext {
/** Which error boundary or subsystem caught this. */
boundary?: string;
/** Next.js error digest, safe to keep — it is an opaque hash. */
digest?: string;
[key: string]: unknown;
}

export function captureError(
error: unknown,
context?: ReportContext,
): ScrubbedError {
const safe = scrubErrorPayload(error, context);

// Until the tracker DSN is wired, surface the *scrubbed* payload only.
// eslint-disable-next-line no-console
console.error("[captureError]", safe);

return safe;
}
108 changes: 108 additions & 0 deletions lib/observability/scrub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Payload scrubbing for the error tracker (issue #76).
*
* ModelTrace is privacy-forward: the error tracker must never become the leak.
* Before any error payload is sent it passes through {@link scrubErrorPayload},
* which strips wallet addresses and request data wherever they appear —
* message strings, stack frames, and nested context objects.
*
* The guarantee that matters is behavioural, and is asserted by
* `tests/unit/scrub.test.ts`: a known address string never survives scrubbing.
*/

export const REDACTED = "[redacted]";

/**
* Stellar public keys / contract ids: base32 (Crockford, RFC-4648 alphabet
* without 0/1/8/9) starting `G` (accounts) or `C` (contracts), 56 chars total.
*/
const STELLAR_ADDRESS = /\b[GC][A-Z2-7]{55}\b/g;

/** Keys whose values are dropped entirely regardless of content. */
const SENSITIVE_KEYS = new Set([
"address",
"publickey",
"public_key",
"secret",
"secretkey",
"seed",
"mnemonic",
"privatekey",
"private_key",
"authorization",
"cookie",
"body",
"requestbody",
"request_body",
"payload",
"headers",
"params",
"searchparams",
"email",
]);

function scrubString(value: string): string {
return value.replace(STELLAR_ADDRESS, REDACTED);
}

function isSensitiveKey(key: string): boolean {
return SENSITIVE_KEYS.has(key.toLowerCase().replace(/[-\s]/g, "_")) ||
SENSITIVE_KEYS.has(key.toLowerCase().replace(/[-\s_]/g, ""));
}

/**
* Recursively scrub an arbitrary value. Strings have addresses masked;
* objects have sensitive keys removed and every other value scrubbed in
* turn. Cyclic references are handled. Depth is bounded so a pathological
* payload can't hang the reporter.
*/
export function scrubValue(value: unknown, _seen?: WeakSet<object>, _depth = 0): unknown {
const seen = _seen ?? new WeakSet<object>();

if (typeof value === "string") return scrubString(value);
if (typeof value !== "object" || value === null) return value;
if (_depth >= 8) return REDACTED;
if (seen.has(value)) return REDACTED;
seen.add(value);

if (Array.isArray(value)) {
return value.map((item) => scrubValue(item, seen, _depth + 1));
}

const out: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
if (isSensitiveKey(key)) {
out[key] = REDACTED;
continue;
}
out[key] = scrubValue(val, seen, _depth + 1);
}
return out;
}

export interface ScrubbedError {
name: string;
message: string;
stack?: string;
context?: Record<string, unknown>;
}

/** Scrub an Error (plus optional structured context) into a safe payload. */
export function scrubErrorPayload(
error: unknown,
context?: Record<string, unknown>,
): ScrubbedError {
const err =
error instanceof Error
? error
: new Error(typeof error === "string" ? error : "Unknown error");

return {
name: err.name,
message: scrubString(err.message),
...(err.stack ? { stack: scrubString(err.stack) } : {}),
...(context
? { context: scrubValue(context) as Record<string, unknown> }
: {}),
};
}
73 changes: 73 additions & 0 deletions tests/unit/scrub.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
scrubErrorPayload,
scrubValue,
REDACTED,
} from "../../lib/observability/scrub";

// A real-shaped Stellar public key (G + 55 base32 chars).
const ADDRESS = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H";

describe("scrubErrorPayload — the address never survives", () => {
it("removes a wallet address from the message", () => {
const out = scrubErrorPayload(
new Error(`wallet ${ADDRESS} failed to connect`),
);
expect(JSON.stringify(out)).not.toContain(ADDRESS);
expect(out.message).toContain(REDACTED);
});

it("removes addresses from the stack trace", () => {
const err = new Error("boom");
err.stack = `Error: boom\n at sign (${ADDRESS})\n at main`;
const out = scrubErrorPayload(err);
expect(JSON.stringify(out)).not.toContain(ADDRESS);
});

it("removes addresses and sensitive keys from nested context", () => {
const out = scrubErrorPayload(new Error("submit failed"), {
route: "/disputes/new",
address: ADDRESS,
request: { body: { reason: "long text", from: ADDRESS } },
nested: [{ note: `paid ${ADDRESS}` }],
});

const serialised = JSON.stringify(out);
expect(serialised).not.toContain(ADDRESS);
expect(out.context?.address).toBe(REDACTED);
expect(out.context?.route).toBe("/disputes/new");
});

it("coerces non-Error throwables", () => {
expect(scrubErrorPayload("string failure").message).toBe("string failure");
expect(scrubErrorPayload(42).name).toBe("Error");
});
});

describe("scrubValue", () => {
it("drops request bodies, headers and auth wherever they appear", () => {
const out = scrubValue({
ok: 1,
headers: { authorization: "Bearer x" },
requestBody: { secret: "s" },
params: { id: "1" },
}) as Record<string, unknown>;
expect(out.ok).toBe(1);
expect(out.headers).toBe(REDACTED);
expect(out.requestBody).toBe(REDACTED);
expect(out.params).toBe(REDACTED);
});

it("handles cyclic references without throwing", () => {
const cyclic: Record<string, unknown> = { a: 1 };
cyclic.self = cyclic;
expect(() => scrubValue(cyclic)).not.toThrow();
});

it("redacts contract ids but leaves ordinary text alone", () => {
const cId = "C" + "A".repeat(55); // C-prefixed 56-char id
expect(scrubValue(`deployed ${cId}`)).toBe(`deployed ${REDACTED}`);
// A short string that merely starts with C is untouched.
expect(scrubValue("Contract deployed")).toBe("Contract deployed");
});
});
Loading