diff --git a/apps/server/src/log.ts b/apps/server/src/log.ts index 7766baadb..d990f739d 100644 --- a/apps/server/src/log.ts +++ b/apps/server/src/log.ts @@ -1,8 +1,15 @@ /** Background errors are visible without logging provider payloads or credential-bearing URLs. */ export function backgroundFailure(phase: string, error: unknown) { + const rawCode = + error instanceof Error && "code" in error + ? (error as Error & { code?: unknown }).code + : undefined; + const code = + typeof rawCode === "string" && /^[A-Za-z0-9_.-]{1,64}$/.test(rawCode) ? rawCode : undefined; console.error({ timestamp: new Date().toISOString(), context: { phase }, error: error instanceof Error ? error.name : "Background operation failed", + ...(code ? { code } : {}), }); } diff --git a/tests/log.test.ts b/tests/log.test.ts new file mode 100644 index 000000000..7004cf8a1 --- /dev/null +++ b/tests/log.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { backgroundFailure } from "../apps/server/src/log.ts"; + +test("background failures retain safe error codes without logging messages", (t) => { + const logged = t.mock.method(console, "error", () => {}); + const error = Object.assign(new Error("postgres://user:secret@example.invalid/database"), { + code: "57P01", + }); + + backgroundFailure("postgres pool", error); + + assert.equal(logged.mock.callCount(), 1); + const [entry] = logged.mock.calls[0].arguments as [Record]; + assert.equal(entry.error, "Error"); + assert.equal(entry.code, "57P01"); + assert.deepEqual(entry.context, { phase: "postgres pool" }); + assert.equal(JSON.stringify(entry).includes("secret"), false); +}); + +test("background failures omit unsafe code strings", (t) => { + const logged = t.mock.method(console, "error", () => {}); + const error = Object.assign(new Error("hidden"), { code: "postgres://secret" }); + + backgroundFailure("background", error); + + const [entry] = logged.mock.calls[0].arguments as [Record]; + assert.equal("code" in entry, false); +});