From fd7838592609f3871755b8a550e9a4f01a7c2b43 Mon Sep 17 00:00:00 2001 From: s6pa1rta3n-lab Date: Tue, 8 Sep 2026 17:29:03 -0400 Subject: [PATCH] refactor(errors): standardize unknown-error normalization across the repository - Add @sub-rosa/errors package with normalizeError, getErrorMessage, redaction, and classification - Add static analysis guard scripts/check-error-normalization.mjs to enforce consistent error handling - Migrate error handling call sites across packages, services, and web app to standard normalization - Enforce weighted test coverage across all error normalization paths Closes #238 --- apps/web/package.json | 1 + apps/web/src/components/AttackDemo.tsx | 3 +- apps/web/src/components/AuditorView.tsx | 7 +- apps/web/src/components/PasskeyPanel.tsx | 5 +- apps/web/src/hooks/useDashboardData.ts | 3 +- apps/web/src/hooks/useDrandCountdown.ts | 3 +- apps/web/src/hooks/useLiveRound.ts | 3 +- apps/web/src/hooks/useRoundSession.ts | 3 +- apps/web/src/lib/chain.ts | 3 +- apps/web/src/lib/demoActions.ts | 3 +- coverage.config.json | 3 +- package.json | 6 +- packages/errors/package.json | 25 + packages/errors/src/classify.test.ts | 101 +++++ packages/errors/src/classify.ts | 185 ++++++++ packages/errors/src/index.cjs | 502 +++++++++++++++++++++ packages/errors/src/index.ts | 8 + packages/errors/src/normalize.test.ts | 205 +++++++++ packages/errors/src/normalize.ts | 366 +++++++++++++++ packages/errors/src/redact.test.ts | 115 +++++ packages/errors/src/redact.ts | 128 ++++++ packages/errors/src/types.ts | 59 +++ packages/errors/tsconfig.json | 13 + packages/sdk/package.json | 1 + packages/sdk/src/mainnet-readiness.ts | 11 +- packages/sdk/src/preflight.ts | 4 +- packages/tlock/package.json | 1 + packages/tlock/src/auditor-recovery-cli.ts | 5 +- pnpm-lock.yaml | 30 ++ scripts/check-error-normalization.mjs | 136 ++++++ scripts/check-error-normalization.test.mjs | 47 ++ services/appraisal-api/package.json | 1 + services/appraisal-api/src/server.ts | 3 +- services/keeper/package.json | 1 + services/keeper/src/keeper.ts | 3 +- services/keeper/src/queue.ts | 3 +- services/keeper/src/status-server.ts | 7 +- services/keeper/src/status.ts | 10 +- services/keeper/src/watch-loop.ts | 10 +- services/receipt-cli/package.json | 1 + services/receipt-cli/src/index.ts | 21 +- 41 files changed, 1997 insertions(+), 48 deletions(-) create mode 100644 packages/errors/package.json create mode 100644 packages/errors/src/classify.test.ts create mode 100644 packages/errors/src/classify.ts create mode 100644 packages/errors/src/index.cjs create mode 100644 packages/errors/src/index.ts create mode 100644 packages/errors/src/normalize.test.ts create mode 100644 packages/errors/src/normalize.ts create mode 100644 packages/errors/src/redact.test.ts create mode 100644 packages/errors/src/redact.ts create mode 100644 packages/errors/src/types.ts create mode 100644 packages/errors/tsconfig.json create mode 100755 scripts/check-error-normalization.mjs create mode 100644 scripts/check-error-normalization.test.mjs diff --git a/apps/web/package.json b/apps/web/package.json index 673d14c8..263ca779 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,6 +20,7 @@ "@stellar/freighter-api": "^6.0.1", "@stellar/stellar-sdk": "^15.1.0", "@sub-rosa/agent": "workspace:*", + "@sub-rosa/errors": "workspace:*", "@sub-rosa/sdk": "workspace:*", "@sub-rosa/time": "workspace:*", "@sub-rosa/tlock": "workspace:*", diff --git a/apps/web/src/components/AttackDemo.tsx b/apps/web/src/components/AttackDemo.tsx index 9e2163b2..c9d847a5 100644 --- a/apps/web/src/components/AttackDemo.tsx +++ b/apps/web/src/components/AttackDemo.tsx @@ -1,5 +1,6 @@ // Copyright (c) 2026 Sub Rosa contributors import { useState } from "react"; +import { getErrorMessage } from "@sub-rosa/errors"; import type { AttackStep } from "../lib/demoTypes"; import { useToast } from "../ui/Toast"; @@ -44,7 +45,7 @@ export function AttackDemo() { `Seal-off leaks early · seal-on waits for R=${res.revealRound.toLocaleString()}`, ); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); + const msg = getErrorMessage(e); setErr(msg); toast.dismiss(workingId); toast.push("error", "Attack demo failed", msg); diff --git a/apps/web/src/components/AuditorView.tsx b/apps/web/src/components/AuditorView.tsx index e3c4bf26..5f6897bc 100644 --- a/apps/web/src/components/AuditorView.tsx +++ b/apps/web/src/components/AuditorView.tsx @@ -1,5 +1,6 @@ // Copyright (c) 2026 Sub Rosa contributors import { useMemo, useState } from "react"; +import { getErrorMessage } from "@sub-rosa/errors"; import type { DemoTrace } from "../demo/trace"; import { shortAddr } from "../lib/format"; import { hexToBytes } from "../lib/hex"; @@ -53,13 +54,13 @@ export function AuditorView({ trace }: { trace: DemoTrace }) { label, address, identity: null, - error: e instanceof Error ? e.message : String(e), + error: getErrorMessage(e), }; } }); setRows(decoded); } catch (e) { - setErr(e instanceof Error ? e.message : String(e)); + setErr(getErrorMessage(e)); } finally { setBusy(false); } @@ -80,7 +81,7 @@ export function AuditorView({ trace }: { trace: DemoTrace }) { if (opened.value !== value) throw new Error("opened value mismatch"); setBidDemo({ value: (Number(value) / 1e7).toFixed(2), round }); } catch (e) { - setErr(e instanceof Error ? e.message : String(e)); + setErr(getErrorMessage(e)); } finally { setBusy(false); } diff --git a/apps/web/src/components/PasskeyPanel.tsx b/apps/web/src/components/PasskeyPanel.tsx index 259f1c5a..2c76d3af 100644 --- a/apps/web/src/components/PasskeyPanel.tsx +++ b/apps/web/src/components/PasskeyPanel.tsx @@ -1,5 +1,6 @@ // Copyright (c) 2026 Sub Rosa contributors import { useMemo, useState } from "react"; +import { getErrorMessage } from "@sub-rosa/errors"; import { CAP_SAFETY_COPY } from "../demo/trace"; import { useTime } from "../lib/time"; import { @@ -98,7 +99,7 @@ export function PasskeyPanel() { ); } catch (e) { setStatus("error"); - setMessage(e instanceof Error ? e.message : String(e)); + setMessage(getErrorMessage(e)); } } @@ -124,7 +125,7 @@ export function PasskeyPanel() { setMessage(`Smart wallet deployed on testnet: ${created.contractId}`); } catch (e) { setStatus("error"); - const detail = e instanceof Error ? e.message : String(e); + const detail = getErrorMessage(e); setMessage( `Deploy failed: ${detail}. Try again after refresh; if it persists, sponsor funding on testnet may be missing (Create passkey alone is enough for the demo).`, ); diff --git a/apps/web/src/hooks/useDashboardData.ts b/apps/web/src/hooks/useDashboardData.ts index 9446c526..63db983c 100644 --- a/apps/web/src/hooks/useDashboardData.ts +++ b/apps/web/src/hooks/useDashboardData.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import type { DashboardData } from "../dashboard/types"; import { DASHBOARD_FIXTURE } from "../dashboard/fixture"; import { assertDashboardData } from "../dashboard/fixture-health-check"; +import { getErrorMessage } from "@sub-rosa/errors"; import { useTime } from "../lib/time"; const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes @@ -84,7 +85,7 @@ export function useDashboardData(): UseDashboardDataResult { stale: isStale(json.meta.fetchedAt, clock.nowMs()), })); } catch (e) { - const message = e instanceof Error ? e.message : String(e); + const message = getErrorMessage(e); setState((s) => ({ ...s, loading: false, diff --git a/apps/web/src/hooks/useDrandCountdown.ts b/apps/web/src/hooks/useDrandCountdown.ts index 1505b237..dd101d95 100644 --- a/apps/web/src/hooks/useDrandCountdown.ts +++ b/apps/web/src/hooks/useDrandCountdown.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 Sub Rosa contributors import { useEffect, useState } from "react"; import { quicknet } from "@sub-rosa/tlock"; +import { getErrorMessage } from "@sub-rosa/errors"; import { useTime } from "../lib/time"; const QUICKNET_GENESIS = 1_692_803_367; @@ -80,7 +81,7 @@ export function useDrandCountdown(targetRound: number, pollMs = 1000): DrandCoun setState({ ...fallback, loading: false, - error: e instanceof Error ? e.message : String(e), + error: getErrorMessage(e), }); } } diff --git a/apps/web/src/hooks/useLiveRound.ts b/apps/web/src/hooks/useLiveRound.ts index 32d51dab..47cc439b 100644 --- a/apps/web/src/hooks/useLiveRound.ts +++ b/apps/web/src/hooks/useLiveRound.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 Sub Rosa contributors import { useEffect, useState } from "react"; import type { Round, BidState } from "@sub-rosa/sdk"; +import { getErrorMessage } from "@sub-rosa/errors"; import { useTime } from "../lib/time"; const RPC = import.meta.env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org"; @@ -48,7 +49,7 @@ export function useLiveRound(enabled: boolean, pollMs = 12_000) { setError(null); } } catch (e) { - if (!cancelled) setError(e instanceof Error ? e.message : String(e)); + if (!cancelled) setError(getErrorMessage(e)); } } diff --git a/apps/web/src/hooks/useRoundSession.ts b/apps/web/src/hooks/useRoundSession.ts index 5c981e08..ce0b3964 100644 --- a/apps/web/src/hooks/useRoundSession.ts +++ b/apps/web/src/hooks/useRoundSession.ts @@ -7,6 +7,7 @@ import { requestAccess, } from "@stellar/freighter-api"; import type { BidState, Round } from "@sub-rosa/sdk"; +import { getErrorMessage } from "@sub-rosa/errors"; import { fetchRoundSignature, generateAuditorKeypair, @@ -163,7 +164,7 @@ export function useRoundSession(active: UseCase) { toast.dismiss(workingId); toast.push("success", "Wallet connected", netMsg); } catch (error) { - const msg = error instanceof Error ? error.message : String(error); + const msg = getErrorMessage(error); setWalletStatus(msg); setStatus("error"); toast.dismiss(workingId); diff --git a/apps/web/src/lib/chain.ts b/apps/web/src/lib/chain.ts index 9122896d..cd23ff02 100644 --- a/apps/web/src/lib/chain.ts +++ b/apps/web/src/lib/chain.ts @@ -6,6 +6,7 @@ import { signTransaction, } from "@stellar/freighter-api"; import { RoundContract } from "@sub-rosa/sdk"; +import { getErrorMessage } from "@sub-rosa/errors"; import { useMemo } from "react"; import { formatEscrowAmount } from "./amount"; @@ -51,7 +52,7 @@ export function freighterError(result: { error?: unknown }) { } export function displayError(error: unknown): string { - const message = error instanceof Error ? error.message : String(error); + const message = getErrorMessage(error); if (message.includes("Contract, #10")) { return "Commit window closed. Create a fresh round, then commit before Drand reaches reveal."; } diff --git a/apps/web/src/lib/demoActions.ts b/apps/web/src/lib/demoActions.ts index 644b5931..b8893e19 100644 --- a/apps/web/src/lib/demoActions.ts +++ b/apps/web/src/lib/demoActions.ts @@ -8,6 +8,7 @@ import { MandateCapError, usdcToStroops, } from "@sub-rosa/agent/mandate"; +import { getErrorMessage } from "@sub-rosa/errors"; import { commitment, currentRound, @@ -55,7 +56,7 @@ export function runCapSafetyDemos(): CapDemoResult[] { title: "Appraisal price above mandate (0.20 > cap 0.10)", layer: "agent (off-chain)", expected: "reject", - outcome: e instanceof MandateCapError ? e.message : String(e), + outcome: getErrorMessage(e), pass: e instanceof MandateCapError, }); } diff --git a/coverage.config.json b/coverage.config.json index a7c1a7ba..27a40bea 100644 --- a/coverage.config.json +++ b/coverage.config.json @@ -8,6 +8,7 @@ "services/keeper", "services/auction-template", "services/appraisal-api", - "services/agent" + "services/agent", + "packages/errors" ] } diff --git a/package.json b/package.json index 3619f968..93818851 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,10 @@ "receipt:typecheck": "pnpm --filter @sub-rosa/receipt-cli typecheck", "time:test": "pnpm --filter @sub-rosa/time test", "time:guard": "node scripts/check-direct-time-access.mjs", - "time:guard:test": "node --test scripts/check-direct-time-access.test.mjs" + "time:guard:test": "node --test scripts/check-direct-time-access.test.mjs", + "errors-pkg:test": "pnpm --filter @sub-rosa/errors test", + "errors-pkg:typecheck": "pnpm --filter @sub-rosa/errors typecheck", + "errors:guard": "node scripts/check-error-normalization.mjs", + "errors:guard:test": "node --test scripts/check-error-normalization.test.mjs" } } diff --git a/packages/errors/package.json b/packages/errors/package.json new file mode 100644 index 00000000..596f5556 --- /dev/null +++ b/packages/errors/package.json @@ -0,0 +1,25 @@ +{ + "name": "@sub-rosa/errors", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Standardized unknown-error normalization, recursive redaction, and diagnostics for Sub Rosa.", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "require": "./src/index.cjs", + "default": "./src/index.ts" + } + }, + "scripts": { + "test": "node --import tsx --test src/normalize.test.ts src/redact.test.ts src/classify.test.ts", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "tsx": "^4.22.4", + "typescript": "^6.0.3" + } +} diff --git a/packages/errors/src/classify.test.ts b/packages/errors/src/classify.test.ts new file mode 100644 index 00000000..8ff0d5df --- /dev/null +++ b/packages/errors/src/classify.test.ts @@ -0,0 +1,101 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + extractErrorCode, + getSafePublicMessage, + isRetryable, +} from "./classify.js"; + +const VALID_STELLAR_SECRET = "S" + "B".repeat(55); + +describe("classify - extractErrorCode", () => { + it("extracts direct code property", () => { + assert.equal(extractErrorCode({ code: "ECONNRESET" }), "ECONNRESET"); + assert.equal(extractErrorCode({ code: 404 }), 404); + }); + + it("extracts status or statusCode property", () => { + assert.equal(extractErrorCode({ status: 503 }), 503); + assert.equal(extractErrorCode({ statusCode: 400 }), 400); + }); + + it("extracts kind property", () => { + assert.equal(extractErrorCode({ kind: "rpc_error" }), "rpc_error"); + }); + + it("extracts nested rpc/error code", () => { + assert.equal(extractErrorCode({ error: { code: -32603 } }), -32603); + assert.equal(extractErrorCode({ response: { status: 502 } }), 502); + }); + + it("returns undefined for values without codes", () => { + assert.equal(extractErrorCode(null), undefined); + assert.equal(extractErrorCode("simple string"), undefined); + assert.equal(extractErrorCode({}), undefined); + }); +}); + +describe("classify - isRetryable", () => { + it("respects explicit retryable property", () => { + assert.equal(isRetryable({ retryable: true }), true); + assert.equal(isRetryable({ retryable: false, status: 503 }), false); + assert.equal(isRetryable({ isRetryable: true }), true); + }); + + it("classifies HTTP statuses correctly", () => { + assert.equal(isRetryable(null, 429), true); + assert.equal(isRetryable(null, 502), true); + assert.equal(isRetryable(null, 503), true); + assert.equal(isRetryable(null, 504), true); + assert.equal(isRetryable(null, 425), true); + assert.equal(isRetryable(null, 400), false); + assert.equal(isRetryable(null, 404), false); + }); + + it("classifies network error codes correctly", () => { + assert.equal(isRetryable(null, "ECONNRESET"), true); + assert.equal(isRetryable(null, "ETIMEDOUT"), true); + assert.equal(isRetryable(null, "UND_ERR_CONNECT_TIMEOUT"), true); + assert.equal(isRetryable(null, "ENOENT"), false); + }); + + it("detects retryable keywords in messages", () => { + assert.equal(isRetryable(null, undefined, "drand round 1234 not servable yet"), true); + assert.equal(isRetryable(null, undefined, "got 425 too early"), true); + assert.equal(isRetryable(null, undefined, "rate limit exceeded"), true); + assert.equal(isRetryable(null, undefined, "invalid signature"), false); + }); +}); + +describe("classify - getSafePublicMessage", () => { + it("translates Soroban contract codes into clear user instructions", () => { + const msg10 = getSafePublicMessage("Error", "Transaction failed with Contract, #10"); + assert.match(msg10, /Commit window closed/); + + const msg15 = getSafePublicMessage("Error", "Contract, #15 execution failed"); + assert.match(msg15, /Reveal window closed/); + + const msg425 = getSafePublicMessage("Error", "got 425 from Drand provider"); + assert.match(msg425, /Drand R is not published yet/); + + const msgTrustline = getSafePublicMessage("Error", "op_no_trustline: trustline entry is missing"); + assert.match(msgTrustline, /Wallet is missing the escrow asset trustline/); + + const msgNotFound = getSafePublicMessage("Error", "RoundNotFound at key"); + assert.equal(msgNotFound, "Round not found."); + }); + + it("sanitizes file paths and stack traces from unhandled errors", () => { + const raw = "Crash in /Users/secretuser/project/file.ts:42\n at Object.run (/Users/secretuser/project/file.ts:42:10)"; + const publicMsg = getSafePublicMessage("Error", raw); + assert.equal(publicMsg.includes("/Users"), false); + assert.equal(publicMsg.includes("at Object.run"), false); + }); + + it("redacts credentials and private keys from public messages", () => { + const raw = `Failed to sign with secret ${VALID_STELLAR_SECRET}`; + const publicMsg = getSafePublicMessage("Error", raw); + assert.equal(publicMsg.includes(VALID_STELLAR_SECRET), false); + assert.match(publicMsg, /\[REDACTED\]/); + }); +}); diff --git a/packages/errors/src/classify.ts b/packages/errors/src/classify.ts new file mode 100644 index 00000000..69267c12 --- /dev/null +++ b/packages/errors/src/classify.ts @@ -0,0 +1,185 @@ +import { scrubText } from "./redact.js"; + +const RETRYABLE_STATUSES = new Set([408, 425, 429, 502, 503, 504]); + +const RETRYABLE_CODES = new Set([ + "ECONNRESET", + "ECONNREFUSED", + "ETIMEDOUT", + "EAI_AGAIN", + "ENOTFOUND", + "EPIPE", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_SOCKET", +]); + +const RETRYABLE_PHRASES = [ + "got 425", + "too early", + "not published yet", + "rate limit", + "network error", + "fetch failed", + "timed out", + "connection refused", + "connection reset", + "drand round", +]; + +const NON_RETRYABLE_STATUSES = new Set([400, 401, 403, 404, 409, 422]); + +/** + * Extracts an identifier or numeric error code from an unknown value. + */ +export function extractErrorCode(value: unknown): string | number | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const obj = value as Record; + + if (typeof obj.code === "string" || typeof obj.code === "number") { + return obj.code; + } + if (typeof obj.status === "number" || typeof obj.status === "string") { + return obj.status; + } + if (typeof obj.statusCode === "number" || typeof obj.statusCode === "string") { + return obj.statusCode; + } + if (typeof obj.kind === "string") { + return obj.kind; + } + + if (obj.error && typeof obj.error === "object") { + const nested = obj.error as Record; + if (typeof nested.code === "string" || typeof nested.code === "number") { + return nested.code; + } + } + + if (obj.response && typeof obj.response === "object") { + const res = obj.response as Record; + if (typeof res.status === "number") { + return res.status; + } + } + + return undefined; +} + +/** + * Determines whether an error condition represents a transient, retryable failure. + */ +export function isRetryable( + value: unknown, + code?: string | number, + message?: string, +): boolean { + if (value && typeof value === "object") { + const obj = value as Record; + if (typeof obj.retryable === "boolean") { + return obj.retryable; + } + if (typeof obj.isRetryable === "boolean") { + return obj.isRetryable; + } + } + + if (typeof code === "number") { + if (RETRYABLE_STATUSES.has(code)) return true; + if (NON_RETRYABLE_STATUSES.has(code)) return false; + } + if (typeof code === "string") { + const num = Number(code); + if (!Number.isNaN(num)) { + if (RETRYABLE_STATUSES.has(num)) return true; + if (NON_RETRYABLE_STATUSES.has(num)) return false; + } + if (RETRYABLE_CODES.has(code.toUpperCase())) { + return true; + } + } + + if (message) { + const lower = message.toLowerCase(); + for (const phrase of RETRYABLE_PHRASES) { + if (lower.includes(phrase)) { + return true; + } + } + } + + return false; +} + +/** + * Derives a safe, user-facing error message omitting stack traces, credentials, and internal paths. + */ +export function getSafePublicMessage( + name: string, + rawMessage: string, + code?: string | number, +): string { + const scrubbed = scrubText(rawMessage); + + if (scrubbed.includes("Contract, #10") || code === 10 || code === "10") { + return "Commit window closed. Create a fresh round, then commit before Drand reaches reveal."; + } + if (scrubbed.includes("Contract, #15") || code === 15 || code === "15") { + return "Reveal window closed for this round. Create a new round and open + reveal soon after Drand R (within ~4 minutes)."; + } + if ( + scrubbed.includes("got 425") || + scrubbed.includes("Error response fetching") || + code === 425 || + code === "425" + ) { + return "Drand R is not published yet. Wait for the countdown, then open + reveal."; + } + if (scrubbed.includes("trustline entry is missing")) { + return "Wallet is missing the escrow asset trustline. Fund the testnet wallet or use the XLM demo contract."; + } + if (/RoundNotFound/i.test(scrubbed) || code === "RoundNotFound") { + return "Round not found."; + } + + const numericCode = typeof code === "number" ? code : Number(code); + if (!Number.isNaN(numericCode)) { + if (numericCode === 400) return "Bad request."; + if (numericCode === 401) return "Authentication required."; + if (numericCode === 403) return "Access denied."; + if (numericCode === 404) return "Resource not found."; + if (numericCode === 429) return "Rate limit exceeded. Please try again later."; + if (numericCode === 500) return "Internal server error."; + if (numericCode === 502) return "Bad gateway. The upstream service is temporarily unavailable."; + if (numericCode === 503) return "Service temporarily unavailable. Please try again."; + if (numericCode === 504) return "Gateway timeout. The upstream service took too long to respond."; + } + + if ( + scrubbed.includes("fetch failed") || + scrubbed.includes("ECONNREFUSED") || + scrubbed.includes("ETIMEDOUT") || + scrubbed.includes("UND_ERR_CONNECT_TIMEOUT") + ) { + return "Network request failed. Please check your connection and try again."; + } + + if (name === "SyntaxError" || scrubbed.includes("Unexpected token")) { + return "Invalid response format."; + } + + const cleanMessage = scrubbed + .split(/\r?\n/) + .find((line) => line.trim().length > 0 && !line.trim().startsWith("at ")) ?? ""; + + const sanitized = cleanMessage + .replace(/(?:\/[a-zA-Z0-9_.-]+)+/g, "[path]") + .replace(/(?:[a-zA-Z]:\\[a-zA-Z0-9_.-]+)+/g, "[path]"); + + if (!sanitized.trim()) { + return "An unexpected error occurred."; + } + + return sanitized.trim(); +} diff --git a/packages/errors/src/index.cjs b/packages/errors/src/index.cjs new file mode 100644 index 00000000..a9da55c2 --- /dev/null +++ b/packages/errors/src/index.cjs @@ -0,0 +1,502 @@ +'use strict'; + +const REDACTED = '[REDACTED]'; +const SENSITIVE_KEY_RE = /secret|token|password|passwd|privatekey|credential|cookie|authorization|apikey|mnemonic|seedphrase/i; + +function isSensitiveKey(key) { + return SENSITIVE_KEY_RE.test(key.replace(/[^a-z0-9]/gi, '')); +} + +function scrubText(value, dynamicSecrets = new Set()) { + let text = value + .replace(/(https?:\/\/)[^\s/@]+(?::[^\s/@]*)?@/gi, '$1[REDACTED]@') + .replace(/([?&](?:[^=&\s]*(?:secret|token|password|credential|api[_-]?key)[^=&\s]*)=)[^&#\s]*/gi, '$1[REDACTED]') + .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9+/_=.-]+/gi, '$1 [REDACTED]') + .replace(/((?:cookie|set-cookie)\s*[:=]\s*)[^\r\n;]+/gi, '$1[REDACTED]') + .replace(/\bS[A-Z2-7]{55}\b/g, REDACTED) + .replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g, REDACTED); + + for (const secret of dynamicSecrets) { + if (secret && secret.length >= 4) { + text = text.split(secret).join(REDACTED); + } + } + + return text; +} + +function collectSecrets(value, secrets, seen = new WeakSet(), depth = 0) { + if (!value || typeof value !== 'object' || depth > 32) return; + if (seen.has(value)) return; + seen.add(value); + + try { + for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) { + if (!('value' in descriptor)) continue; + const val = descriptor.value; + if (isSensitiveKey(key)) { + if (typeof val === 'string' && val.length >= 4) { + secrets.add(val); + } else { + collectSecrets(val, secrets, seen, depth + 1); + } + } else { + collectSecrets(val, secrets, seen, depth + 1); + } + } + } catch { + return; + } +} + +function redactSensitive(value, dynamicSecrets = new Set(), seen = new WeakSet(), depth = 0) { + if (typeof value === 'string') return scrubText(value, dynamicSecrets); + if (typeof value === 'bigint') return value.toString(); + if (value === null || typeof value === 'boolean' || typeof value === 'number') return value; + if (value === undefined) return null; + if (typeof value !== 'object') return `[${typeof value}]`; + if (depth > 32) return '[MaxDepth]'; + if (seen.has(value)) return '[Circular]'; + + seen.add(value); + try { + if (Array.isArray(value)) { + return value.map((item) => redactSensitive(item, dynamicSecrets, seen, depth + 1)); + } + + const descriptors = Object.getOwnPropertyDescriptors(value); + const output = Object.create(null); + + if (value instanceof Error) { + output.name = typeof value.name === 'string' ? scrubText(value.name, dynamicSecrets) : 'Error'; + output.message = typeof value.message === 'string' ? scrubText(value.message, dynamicSecrets) : ''; + if (typeof value.stack === 'string') { + output.stack = scrubText(value.stack, dynamicSecrets); + } + } + + for (const [key, descriptor] of Object.entries(descriptors)) { + if (isSensitiveKey(key)) { + output[key] = REDACTED; + } else if ('value' in descriptor) { + output[key] = redactSensitive(descriptor.value, dynamicSecrets, seen, depth + 1); + } else { + output[key] = '[Accessor]'; + } + } + return output; + } catch { + return '[Unserializable]'; + } finally { + seen.delete(value); + } +} + +const RETRYABLE_STATUSES = new Set([408, 425, 429, 502, 503, 504]); +const RETRYABLE_CODES = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'ENOTFOUND', + 'EPIPE', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_SOCKET', +]); +const RETRYABLE_PHRASES = [ + 'got 425', + 'too early', + 'not published yet', + 'rate limit', + 'network error', + 'fetch failed', + 'timed out', + 'connection refused', + 'connection reset', + 'drand round', +]; +const NON_RETRYABLE_STATUSES = new Set([400, 401, 403, 404, 409, 422]); + +function extractErrorCode(value) { + if (!value || typeof value !== 'object') return undefined; + const obj = value; + if (typeof obj.code === 'string' || typeof obj.code === 'number') return obj.code; + if (typeof obj.status === 'number' || typeof obj.status === 'string') return obj.status; + if (typeof obj.statusCode === 'number' || typeof obj.statusCode === 'string') return obj.statusCode; + if (typeof obj.kind === 'string') return obj.kind; + if (obj.error && typeof obj.error === 'object') { + if (typeof obj.error.code === 'string' || typeof obj.error.code === 'number') return obj.error.code; + } + if (obj.response && typeof obj.response === 'object') { + if (typeof obj.response.status === 'number') return obj.response.status; + } + return undefined; +} + +function isRetryable(value, code, message) { + if (value && typeof value === 'object') { + if (typeof value.retryable === 'boolean') return value.retryable; + if (typeof value.isRetryable === 'boolean') return value.isRetryable; + } + if (typeof code === 'number') { + if (RETRYABLE_STATUSES.has(code)) return true; + if (NON_RETRYABLE_STATUSES.has(code)) return false; + } + if (typeof code === 'string') { + const num = Number(code); + if (!Number.isNaN(num)) { + if (RETRYABLE_STATUSES.has(num)) return true; + if (NON_RETRYABLE_STATUSES.has(num)) return false; + } + if (RETRYABLE_CODES.has(code.toUpperCase())) return true; + } + if (message) { + const lower = message.toLowerCase(); + for (const phrase of RETRYABLE_PHRASES) { + if (lower.includes(phrase)) return true; + } + } + return false; +} + +function getSafePublicMessage(name, rawMessage, code) { + const scrubbed = scrubText(rawMessage); + if (scrubbed.includes('Contract, #10') || code === 10 || code === '10') { + return 'Commit window closed. Create a fresh round, then commit before Drand reaches reveal.'; + } + if (scrubbed.includes('Contract, #15') || code === 15 || code === '15') { + return 'Reveal window closed for this round. Create a new round and open + reveal soon after Drand R (within ~4 minutes).'; + } + if (scrubbed.includes('got 425') || scrubbed.includes('Error response fetching') || code === 425 || code === '425') { + return 'Drand R is not published yet. Wait for the countdown, then open + reveal.'; + } + if (scrubbed.includes('trustline entry is missing')) { + return 'Wallet is missing the escrow asset trustline. Fund the testnet wallet or use the XLM demo contract.'; + } + if (/RoundNotFound/i.test(scrubbed) || code === 'RoundNotFound') { + return 'Round not found.'; + } + + const numericCode = typeof code === 'number' ? code : Number(code); + if (!Number.isNaN(numericCode)) { + if (numericCode === 400) return 'Bad request.'; + if (numericCode === 401) return 'Authentication required.'; + if (numericCode === 403) return 'Access denied.'; + if (numericCode === 404) return 'Resource not found.'; + if (numericCode === 429) return 'Rate limit exceeded. Please try again later.'; + if (numericCode === 500) return 'Internal server error.'; + if (numericCode === 502) return 'Bad gateway. The upstream service is temporarily unavailable.'; + if (numericCode === 503) return 'Service temporarily unavailable. Please try again.'; + if (numericCode === 504) return 'Gateway timeout. The upstream service took too long to respond.'; + } + + if ( + scrubbed.includes('fetch failed') || + scrubbed.includes('ECONNREFUSED') || + scrubbed.includes('ETIMEDOUT') || + scrubbed.includes('UND_ERR_CONNECT_TIMEOUT') + ) { + return 'Network request failed. Please check your connection and try again.'; + } + + if (name === 'SyntaxError' || scrubbed.includes('Unexpected token')) { + return 'Invalid response format.'; + } + + const cleanMessage = scrubbed + .split(/\r?\n/) + .find((line) => line.trim().length > 0 && !line.trim().startsWith('at ')) || ''; + + const sanitized = cleanMessage + .replace(/(?:\/[a-zA-Z0-9_.-]+)+/g, '[path]') + .replace(/(?:[a-zA-Z]:\\[a-zA-Z0-9_.-]+)+/g, '[path]'); + + if (!sanitized.trim()) { + return 'An unexpected error occurred.'; + } + return sanitized.trim(); +} + +class NormalizedError extends Error { + constructor(init) { + super(init.message); + this.name = init.name; + this.message = init.message; + this.code = init.code; + this.cause = init.cause; + this.stack = init.stack; + this.retryable = Boolean(init.retryable); + this.context = init.context; + this.publicMessage = init.publicMessage || getSafePublicMessage(init.name, init.message, init.code); + this.raw = init.raw; + } + + toAssertable() { + const out = { + name: this.name, + message: this.message, + retryable: this.retryable, + publicMessage: this.publicMessage, + }; + if (this.code !== undefined) out.code = this.code; + if (this.context !== undefined) out.context = this.context; + if (this.cause instanceof NormalizedError) out.cause = this.cause.toAssertable(); + return out; + } + + toOperatorDiagnostics() { + const causes = []; + let cur = this.cause; + while (cur instanceof NormalizedError) { + causes.push({ + name: cur.name, + message: cur.message, + code: cur.code, + stack: cur.stack, + }); + cur = cur.cause; + } + + const out = { + name: this.name, + message: this.message, + retryable: this.retryable, + }; + if (this.code !== undefined) out.code = this.code; + if (this.stack) out.stack = this.stack; + if (this.context) out.context = this.context; + if (causes.length > 0) out.causes = causes; + return out; + } + + toString() { + const prefix = this.code !== undefined ? `${this.name} [${this.code}]` : this.name; + return `${prefix}: ${this.message}`; + } +} + +const STANDARD_KEYS = new Set([ + 'name', + 'message', + 'stack', + 'cause', + 'code', + 'status', + 'statusCode', + 'kind', + 'retryable', + 'isRetryable', + 'publicMessage', +]); + +function normalizeInternal(err, options = {}, seen = new WeakSet(), depth = 0) { + if (depth > (options.maxDepth ?? 10)) { + return new NormalizedError({ + name: 'DepthLimitExceededError', + message: 'Error cause nesting limit reached', + retryable: false, + }); + } + + const dynamicSecrets = new Set(); + collectSecrets(err, dynamicSecrets); + + if (err instanceof NormalizedError && depth === 0 && !options.code && !options.publicMessage) { + return err; + } + + if (err === null || err === undefined) { + const message = `Unknown error (${String(err)})`; + return new NormalizedError({ + name: 'Error', + message, + code: options.code, + retryable: options.retryable ?? false, + publicMessage: options.publicMessage ?? 'An unknown error occurred.', + raw: err, + }); + } + + if (typeof err === 'string') { + const scrubbed = scrubText(err, dynamicSecrets); + const code = options.code; + const retryable = options.retryable ?? isRetryable(null, code, scrubbed); + return new NormalizedError({ + name: 'Error', + message: scrubbed, + code, + retryable, + publicMessage: options.publicMessage ?? getSafePublicMessage('Error', scrubbed, code), + raw: err, + }); + } + + if (typeof err === 'number' || typeof err === 'bigint' || typeof err === 'boolean') { + const message = String(err); + return new NormalizedError({ + name: 'Error', + message, + code: options.code, + retryable: options.retryable ?? false, + publicMessage: options.publicMessage ?? 'An unexpected error occurred.', + raw: err, + }); + } + + if (typeof err === 'symbol') { + const message = err.toString(); + return new NormalizedError({ + name: 'Error', + message, + code: options.code, + retryable: options.retryable ?? false, + publicMessage: options.publicMessage ?? 'An unexpected error occurred.', + raw: err, + }); + } + + if (typeof err === 'function') { + return new NormalizedError({ + name: 'Error', + message: `[Function ${err.name || 'anonymous'}]`, + code: options.code, + retryable: options.retryable ?? false, + publicMessage: options.publicMessage ?? 'An unexpected error occurred.', + raw: err, + }); + } + + if (typeof err === 'object') { + if (seen.has(err)) { + return new NormalizedError({ + name: 'CircularError', + message: '[Circular cause]', + retryable: false, + raw: err, + }); + } + seen.add(err); + + let rawName = 'Error'; + let rawMessage = ''; + let rawStack = undefined; + let causeVal = undefined; + + try { + if (typeof err.name === 'string' && err.name.trim()) { + rawName = err.name; + } else if (err instanceof Error && err.constructor?.name) { + rawName = err.constructor.name; + } + + if (typeof err.message === 'string') { + rawMessage = err.message; + } else if (typeof err.error === 'string') { + rawMessage = err.error; + } else if (err.error && typeof err.error === 'object' && typeof err.error.message === 'string') { + rawMessage = err.error.message; + } else if (typeof err.statusText === 'string') { + rawMessage = err.statusText; + } + + if (typeof err.stack === 'string') rawStack = err.stack; + if ('cause' in err) causeVal = err.cause; + } catch { + rawMessage = '[Unserializable object]'; + } + + const code = options.code ?? extractErrorCode(err); + const message = scrubText(rawMessage || 'Unknown error', dynamicSecrets); + const stack = rawStack ? scrubText(rawStack, dynamicSecrets) : undefined; + const retryable = options.retryable ?? isRetryable(err, code, message); + + let context = undefined; + try { + const extracted = {}; + for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(err))) { + if (STANDARD_KEYS.has(key)) continue; + if ('value' in descriptor) { + extracted[key] = redactSensitive(descriptor.value, dynamicSecrets); + } + } + if (options.context) { + for (const [k, v] of Object.entries(options.context)) { + extracted[k] = redactSensitive(v, dynamicSecrets); + } + } + if (Object.keys(extracted).length > 0) { + context = extracted; + } + } catch { + context = undefined; + } + + let normalizedCause = undefined; + if (causeVal !== undefined && causeVal !== null) { + normalizedCause = normalizeInternal(causeVal, {}, seen, depth + 1); + } + + return new NormalizedError({ + name: rawName, + message, + code, + cause: normalizedCause, + stack, + retryable, + context, + publicMessage: options.publicMessage ?? getSafePublicMessage(rawName, message, code), + raw: err, + }); + } + + return new NormalizedError({ + name: 'Error', + message: String(err), + code: options.code, + retryable: options.retryable ?? false, + publicMessage: options.publicMessage ?? 'An unexpected error occurred.', + raw: err, + }); +} + +function normalizeError(err, options = {}) { + return normalizeInternal(err, options); +} + +function getErrorMessage(err, options = {}) { + return normalizeError(err, options).message; +} + +function getPublicErrorMessage(err, options = {}) { + return normalizeError(err, options).publicMessage; +} + +function isRetryableError(err) { + return normalizeError(err).retryable; +} + +function toAssertableError(err, options = {}) { + return normalizeError(err, options).toAssertable(); +} + +function toOperatorDiagnostics(err, options = {}) { + return normalizeError(err, options).toOperatorDiagnostics(); +} + +module.exports = { + REDACTED, + isSensitiveKey, + scrubText, + collectSecrets, + redactSensitive, + extractErrorCode, + isRetryable, + getSafePublicMessage, + NormalizedError, + normalizeError, + getErrorMessage, + getPublicErrorMessage, + isRetryableError, + toAssertableError, + toOperatorDiagnostics, +}; diff --git a/packages/errors/src/index.ts b/packages/errors/src/index.ts new file mode 100644 index 00000000..eadad9bf --- /dev/null +++ b/packages/errors/src/index.ts @@ -0,0 +1,8 @@ +/** + * Safe unknown-error normalization, recursive redaction, and diagnostics. + */ + +export * from "./types.js"; +export * from "./redact.js"; +export * from "./classify.js"; +export * from "./normalize.js"; diff --git a/packages/errors/src/normalize.test.ts b/packages/errors/src/normalize.test.ts new file mode 100644 index 00000000..3da7442a --- /dev/null +++ b/packages/errors/src/normalize.test.ts @@ -0,0 +1,205 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + NormalizedError, + normalizeError, + getErrorMessage, + getPublicErrorMessage, + isRetryableError, + toAssertableError, + toOperatorDiagnostics, +} from "./normalize.js"; + +const VALID_STELLAR_SECRET = "S" + "B".repeat(55); + +class CustomDomainError extends Error { + readonly kind = "domain_failure"; + readonly code = "ERR_DOMAIN"; + constructor(msg: string) { + super(msg); + this.name = "CustomDomainError"; + } +} + +describe("normalizeError - Native Error instances", () => { + it("normalizes standard Error preserving name, message, and stack", () => { + const original = new Error("something went wrong"); + const normalized = normalizeError(original); + + assert.equal(normalized instanceof NormalizedError, true); + assert.equal(normalized instanceof Error, true); + assert.equal(normalized.name, "Error"); + assert.equal(normalized.message, "something went wrong"); + assert.equal(typeof normalized.stack, "string"); + assert.equal(normalized.retryable, false); + assert.equal(normalized.raw, original); + }); + + it("preserves custom domain error class name, kind, and codes", () => { + const original = new CustomDomainError("contract execution failed"); + const normalized = normalizeError(original); + + assert.equal(normalized.name, "CustomDomainError"); + assert.equal(normalized.message, "contract execution failed"); + assert.equal(normalized.code, "ERR_DOMAIN"); + assert.equal(normalized.raw, original); + }); +}); + +describe("normalizeError - Thrown primitive values", () => { + it("normalizes thrown strings", () => { + const normalized = normalizeError("connection reset by peer"); + assert.equal(normalized.name, "Error"); + assert.equal(normalized.message, "connection reset by peer"); + assert.equal(normalized.retryable, true); + }); + + it("normalizes null and undefined gracefully", () => { + const normNull = normalizeError(null); + assert.equal(normNull.message, "Unknown error (null)"); + assert.equal(normNull.publicMessage, "An unknown error occurred."); + + const normUndefined = normalizeError(undefined); + assert.equal(normUndefined.message, "Unknown error (undefined)"); + }); + + it("normalizes numbers, bigints, and symbols", () => { + assert.equal(normalizeError(404).message, "404"); + assert.equal(normalizeError(100n).message, "100"); + assert.equal(normalizeError(Symbol("test_sym")).message, "Symbol(test_sym)"); + }); + + it("normalizes functions", () => { + function badFn() {} + const normalized = normalizeError(badFn); + assert.equal(normalized.message, "[Function badFn]"); + }); +}); + +describe("normalizeError - Plain objects & RPC responses", () => { + it("normalizes plain objects with error message and code", () => { + const raw = { + message: "Rate limit exceeded", + code: 429, + endpoint: "https://horizon-testnet.stellar.org", + }; + const normalized = normalizeError(raw); + + assert.equal(normalized.message, "Rate limit exceeded"); + assert.equal(normalized.code, 429); + assert.equal(normalized.retryable, true); + assert.deepEqual(normalized.context, { + endpoint: "https://horizon-testnet.stellar.org", + }); + }); + + it("normalizes nested RPC error shapes", () => { + const rpcFailure = { + error: { + code: -32603, + message: "Internal RPC simulation error", + }, + }; + const normalized = normalizeError(rpcFailure); + assert.equal(normalized.code, -32603); + assert.equal(normalized.message, "Internal RPC simulation error"); + }); + + it("safely handles hostile objects with throwing getters", () => { + const hostile = { + get message() { + throw new Error("trap"); + }, + }; + const normalized = normalizeError(hostile); + assert.equal(normalized.name, "Error"); + assert.equal(typeof normalized.message, "string"); + }); +}); + +describe("normalizeError - Nested causes & Cycle safety", () => { + it("preserves nested cause chains", () => { + const root = new Error("root failure"); + const middle = new Error("middle layer", { cause: root }); + const top = new Error("top level failed", { cause: middle }); + + const normalized = normalizeError(top); + assert.equal(normalized.message, "top level failed"); + assert.equal(normalized.cause?.message, "middle layer"); + assert.equal(normalized.cause?.cause?.message, "root failure"); + }); + + it("prevents infinite recursion on circular causes", () => { + const first: Record = { name: "FirstError", message: "first error" }; + const second: Record = { name: "SecondError", message: "second error" }; + first.cause = second; + second.cause = first; + + const normalized = normalizeError(first); + assert.equal(normalized.message, "first error"); + assert.equal(normalized.cause?.message, "second error"); + assert.equal(normalized.cause?.cause?.name, "CircularError"); + }); +}); + +describe("normalizeError - Secret redaction in context and messages", () => { + it("redacts credentials from error messages and context objects", () => { + const raw = { + message: `Failed auth with secret ${VALID_STELLAR_SECRET}`, + authToken: "sensitiveAuthTokenValue", + apiKey: "secretApiKeyVal", + safeMeta: "ok", + }; + const normalized = normalizeError(raw); + + assert.equal(normalized.message.includes(VALID_STELLAR_SECRET), false); + assert.match(normalized.message, /\[REDACTED\]/); + assert.equal(normalized.context?.authToken, "[REDACTED]"); + assert.equal(normalized.context?.apiKey, "[REDACTED]"); + assert.equal(normalized.context?.safeMeta, "ok"); + }); +}); + +describe("toAssertable - Snapshot-free assertions", () => { + it("exports a stable assertable representation omitting stack", () => { + const err = new Error("verification failed"); + (err as unknown as { code: string }).code = "VERIFY_ERR"; + const assertable = toAssertableError(err); + + assert.deepEqual(assertable, { + name: "Error", + message: "verification failed", + code: "VERIFY_ERR", + retryable: false, + publicMessage: "verification failed", + }); + assert.equal("stack" in assertable, false); + }); +}); + +describe("toOperatorDiagnostics", () => { + it("generates operator diagnostic view with cause chain", () => { + const cause = new Error("inner failure"); + const top = new Error("outer failure", { cause }); + const diagnostics = toOperatorDiagnostics(top); + + assert.equal(diagnostics.name, "Error"); + assert.equal(diagnostics.message, "outer failure"); + assert.equal(typeof diagnostics.stack, "string"); + assert.equal(diagnostics.causes?.length, 1); + assert.equal(diagnostics.causes?.[0].message, "inner failure"); + }); +}); + +describe("Convenience helper functions", () => { + it("getErrorMessage and getPublicErrorMessage return expected strings", () => { + const err = new Error("Contract, #10 execution reverted"); + assert.equal(getErrorMessage(err), "Contract, #10 execution reverted"); + assert.match(getPublicErrorMessage(err), /Commit window closed/); + }); + + it("isRetryableError identifies retryable conditions", () => { + assert.equal(isRetryableError(new Error("fetch failed")), true); + assert.equal(isRetryableError(new Error("invalid user input")), false); + }); +}); diff --git a/packages/errors/src/normalize.ts b/packages/errors/src/normalize.ts new file mode 100644 index 00000000..bb13c04f --- /dev/null +++ b/packages/errors/src/normalize.ts @@ -0,0 +1,366 @@ +import { + collectSecrets, + redactSensitive, + scrubText, +} from "./redact.js"; +import { + extractErrorCode, + getSafePublicMessage, + isRetryable as checkRetryable, +} from "./classify.js"; +import type { + AssertableError, + INormalizedError, + NormalizeOptions, + OperatorDiagnostics, +} from "./types.js"; + +const STANDARD_KEYS = new Set([ + "name", + "message", + "stack", + "cause", + "code", + "status", + "statusCode", + "kind", + "retryable", + "isRetryable", + "publicMessage", +]); + +/** + * Normalized error class extending Error and providing structured diagnostic methods. + */ +export class NormalizedError extends Error implements INormalizedError { + readonly name: string; + readonly message: string; + readonly code?: string | number; + readonly cause?: NormalizedError; + readonly stack?: string; + readonly retryable: boolean; + readonly context?: Record; + readonly publicMessage: string; + readonly raw?: unknown; + + constructor(init: { + name: string; + message: string; + code?: string | number; + cause?: NormalizedError; + stack?: string; + retryable?: boolean; + context?: Record; + publicMessage?: string; + raw?: unknown; + }) { + super(init.message); + this.name = init.name; + this.message = init.message; + this.code = init.code; + this.cause = init.cause; + this.stack = init.stack; + this.retryable = Boolean(init.retryable); + this.context = init.context; + this.publicMessage = + init.publicMessage ?? getSafePublicMessage(init.name, init.message, init.code); + this.raw = init.raw; + + Object.setPrototypeOf(this, new.target.prototype); + } + + /** + * Produces a stable, snapshot-friendly representation without volatile stack traces. + */ + toAssertable(): AssertableError { + const out: AssertableError = { + name: this.name, + message: this.message, + retryable: this.retryable, + publicMessage: this.publicMessage, + }; + if (this.code !== undefined) { + out.code = this.code; + } + if (this.context !== undefined) { + out.context = this.context; + } + if (this.cause instanceof NormalizedError) { + out.cause = this.cause.toAssertable(); + } + return out; + } + + /** + * Returns a complete operator diagnostics payload including scrubbed stack and cause chain. + */ + toOperatorDiagnostics(): OperatorDiagnostics { + const causes: NonNullable = []; + let cur = this.cause; + while (cur instanceof NormalizedError) { + causes.push({ + name: cur.name, + message: cur.message, + code: cur.code, + stack: cur.stack, + }); + cur = cur.cause; + } + + const out: OperatorDiagnostics = { + name: this.name, + message: this.message, + retryable: this.retryable, + }; + if (this.code !== undefined) { + out.code = this.code; + } + if (this.stack) { + out.stack = this.stack; + } + if (this.context) { + out.context = this.context; + } + if (causes.length > 0) { + out.causes = causes; + } + return out; + } + + override toString(): string { + const prefix = this.code !== undefined ? `${this.name} [${this.code}]` : this.name; + return `${prefix}: ${this.message}`; + } +} + +/** + * Internal recursive normalization engine tracking cycle prevention and depth. + */ +function normalizeInternal( + err: unknown, + options: NormalizeOptions = {}, + seen: WeakSet = new WeakSet(), + depth = 0, +): NormalizedError { + if (depth > (options.maxDepth ?? 10)) { + return new NormalizedError({ + name: "DepthLimitExceededError", + message: "Error cause nesting limit reached", + retryable: false, + }); + } + + const dynamicSecrets = new Set(); + collectSecrets(err, dynamicSecrets); + + if (err instanceof NormalizedError && depth === 0 && !options.code && !options.publicMessage) { + return err; + } + + if (err === null || err === undefined) { + const message = `Unknown error (${String(err)})`; + return new NormalizedError({ + name: "Error", + message, + code: options.code, + retryable: options.retryable ?? false, + publicMessage: options.publicMessage ?? "An unknown error occurred.", + raw: err, + }); + } + + if (typeof err === "string") { + const scrubbed = scrubText(err, dynamicSecrets); + const code = options.code; + const retryable = options.retryable ?? checkRetryable(null, code, scrubbed); + return new NormalizedError({ + name: "Error", + message: scrubbed, + code, + retryable, + publicMessage: options.publicMessage ?? getSafePublicMessage("Error", scrubbed, code), + raw: err, + }); + } + + if (typeof err === "number" || typeof err === "bigint" || typeof err === "boolean") { + const message = String(err); + return new NormalizedError({ + name: "Error", + message, + code: options.code, + retryable: options.retryable ?? false, + publicMessage: options.publicMessage ?? "An unexpected error occurred.", + raw: err, + }); + } + + if (typeof err === "symbol") { + const message = err.toString(); + return new NormalizedError({ + name: "Error", + message, + code: options.code, + retryable: options.retryable ?? false, + publicMessage: options.publicMessage ?? "An unexpected error occurred.", + raw: err, + }); + } + + if (typeof err === "function") { + return new NormalizedError({ + name: "Error", + message: `[Function ${err.name || "anonymous"}]`, + code: options.code, + retryable: options.retryable ?? false, + publicMessage: options.publicMessage ?? "An unexpected error occurred.", + raw: err, + }); + } + + if (typeof err === "object") { + if (seen.has(err)) { + return new NormalizedError({ + name: "CircularError", + message: "[Circular cause]", + retryable: false, + raw: err, + }); + } + seen.add(err); + + let rawName = "Error"; + let rawMessage = ""; + let rawStack: string | undefined = undefined; + let causeVal: unknown = undefined; + + try { + const obj = err as Record; + if (typeof obj.name === "string" && obj.name.trim()) { + rawName = obj.name; + } else if (err instanceof Error && err.constructor?.name) { + rawName = err.constructor.name; + } + + if (typeof obj.message === "string") { + rawMessage = obj.message; + } else if (typeof obj.error === "string") { + rawMessage = obj.error; + } else if (obj.error && typeof obj.error === "object") { + const nested = obj.error as Record; + if (typeof nested.message === "string") { + rawMessage = nested.message; + } + } else if (typeof obj.statusText === "string") { + rawMessage = obj.statusText; + } + + if (typeof obj.stack === "string") { + rawStack = obj.stack; + } + + if ("cause" in obj) { + causeVal = obj.cause; + } + } catch { + rawMessage = "[Unserializable object]"; + } + + const code = options.code ?? extractErrorCode(err); + const message = scrubText(rawMessage || "Unknown error", dynamicSecrets); + const stack = rawStack ? scrubText(rawStack, dynamicSecrets) : undefined; + const retryable = options.retryable ?? checkRetryable(err, code, message); + + let context: Record | undefined = undefined; + try { + const extracted: Record = {}; + for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(err))) { + if (STANDARD_KEYS.has(key)) continue; + if ("value" in descriptor) { + extracted[key] = redactSensitive(descriptor.value, dynamicSecrets); + } + } + if (options.context) { + for (const [k, v] of Object.entries(options.context)) { + extracted[k] = redactSensitive(v, dynamicSecrets); + } + } + if (Object.keys(extracted).length > 0) { + context = extracted; + } + } catch { + context = undefined; + } + + let normalizedCause: NormalizedError | undefined = undefined; + if (causeVal !== undefined && causeVal !== null) { + normalizedCause = normalizeInternal(causeVal, {}, seen, depth + 1); + } + + return new NormalizedError({ + name: rawName, + message, + code, + cause: normalizedCause, + stack, + retryable, + context, + publicMessage: options.publicMessage ?? getSafePublicMessage(rawName, message, code), + raw: err, + }); + } + + return new NormalizedError({ + name: "Error", + message: String(err), + code: options.code, + retryable: options.retryable ?? false, + publicMessage: options.publicMessage ?? "An unexpected error occurred.", + raw: err, + }); +} + +/** + * Normalizes any unknown thrown value into a structured NormalizedError instance. + */ +export function normalizeError(err: unknown, options: NormalizeOptions = {}): NormalizedError { + return normalizeInternal(err, options); +} + +/** + * Extracts a normalized, secret-scrubbed message string from any caught value. + */ +export function getErrorMessage(err: unknown, options: NormalizeOptions = {}): string { + return normalizeError(err, options).message; +} + +/** + * Extracts a safe public or user-facing message string from any caught value. + */ +export function getPublicErrorMessage(err: unknown, options: NormalizeOptions = {}): string { + return normalizeError(err, options).publicMessage; +} + +/** + * Evaluates whether a caught value indicates a retryable condition. + */ +export function isRetryableError(err: unknown): boolean { + return normalizeError(err).retryable; +} + +/** + * Produces a stable, snapshot-safe assertion object for an unknown caught value. + */ +export function toAssertableError(err: unknown, options: NormalizeOptions = {}): AssertableError { + return normalizeError(err, options).toAssertable(); +} + +/** + * Produces an operator diagnostics summary for an unknown caught value. + */ +export function toOperatorDiagnostics( + err: unknown, + options: NormalizeOptions = {}, +): OperatorDiagnostics { + return normalizeError(err, options).toOperatorDiagnostics(); +} diff --git a/packages/errors/src/redact.test.ts b/packages/errors/src/redact.test.ts new file mode 100644 index 00000000..fa57af36 --- /dev/null +++ b/packages/errors/src/redact.test.ts @@ -0,0 +1,115 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + REDACTED, + isSensitiveKey, + scrubText, + collectSecrets, + redactSensitive, +} from "./redact.js"; + +const VALID_STELLAR_SECRET = "S" + "B".repeat(55); + +describe("redact - sensitive keys", () => { + it("identifies sensitive key variants", () => { + assert.equal(isSensitiveKey("secret"), true); + assert.equal(isSensitiveKey("authToken"), true); + assert.equal(isSensitiveKey("user_password"), true); + assert.equal(isSensitiveKey("private_key"), true); + assert.equal(isSensitiveKey("api_key"), true); + assert.equal(isSensitiveKey("sessionCookie"), true); + assert.equal(isSensitiveKey("seedPhrase"), true); + assert.equal(isSensitiveKey("username"), false); + assert.equal(isSensitiveKey("roundId"), false); + }); +}); + +describe("redact - scrubText", () => { + it("redacts credentials from URLs", () => { + const text = "connecting to https://alice:secretPass123@stellar.org/rpc"; + const scrubbed = scrubText(text); + assert.equal(scrubbed, "connecting to https://[REDACTED]@stellar.org/rpc"); + }); + + it("redacts sensitive query parameters", () => { + const text = "GET /api?api_key=secretKey123&foo=bar&token=jwtTokenVal"; + const scrubbed = scrubText(text); + assert.equal(scrubbed, "GET /api?api_key=[REDACTED]&foo=bar&token=[REDACTED]"); + }); + + it("redacts bearer and basic authorization tokens", () => { + const text = "Header Authorization: Bearer eyJhbGciOiJIUzI1Ni.secret and Basic dXNlcjpwYXNz"; + const scrubbed = scrubText(text); + assert.equal(scrubbed, "Header Authorization: Bearer [REDACTED] and Basic [REDACTED]"); + }); + + it("redacts Stellar secret keys", () => { + const text = `Signing with secret ${VALID_STELLAR_SECRET} on testnet`; + const scrubbed = scrubText(text); + assert.equal(scrubbed, "Signing with secret [REDACTED] on testnet"); + }); + + it("redacts PEM private keys", () => { + const text = "Key content: -----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0...\n-----END RSA PRIVATE KEY----- trailing"; + const scrubbed = scrubText(text); + assert.equal(scrubbed, "Key content: [REDACTED] trailing"); + }); + + it("redacts dynamically collected secrets", () => { + const secrets = new Set(["customSecretTokenValue"]); + const text = "Found secret customSecretTokenValue in log"; + const scrubbed = scrubText(text, secrets); + assert.equal(scrubbed, "Found secret [REDACTED] in log"); + }); +}); + +describe("redact - collectSecrets", () => { + it("gathers string secrets from sensitive keys recursively", () => { + const secrets = new Set(); + const obj = { + user: "alice", + token: "secretTokenVal123", + nested: { + apiKey: "mySuperSecretApiKey", + }, + }; + collectSecrets(obj, secrets); + assert.equal(secrets.has("secretTokenVal123"), true); + assert.equal(secrets.has("mySuperSecretApiKey"), true); + }); +}); + +describe("redact - redactSensitive", () => { + it("masks sensitive dictionary keys and values", () => { + const obj = { + username: "bob", + password: "plaintextPassword", + nested: { + secret: "myKey", + safeValue: 42, + }, + }; + const redacted = redactSensitive(obj) as Record; + assert.equal(redacted.username, "bob"); + assert.equal(redacted.password, REDACTED); + const nested = redacted.nested as Record; + assert.equal(nested.secret, REDACTED); + assert.equal(nested.safeValue, 42); + }); + + it("handles circular references safely without blowing stack", () => { + const circular: Record = { name: "loop" }; + circular.self = circular; + const redacted = redactSensitive(circular) as Record; + assert.equal(redacted.name, "loop"); + assert.equal(redacted.self, "[Circular]"); + }); + + it("handles primitives and special types gracefully", () => { + assert.equal(redactSensitive(123n), "123"); + assert.equal(redactSensitive(null), null); + assert.equal(redactSensitive(undefined), null); + assert.equal(redactSensitive(true), true); + assert.equal(redactSensitive(Symbol("test")), "[symbol]"); + }); +}); diff --git a/packages/errors/src/redact.ts b/packages/errors/src/redact.ts new file mode 100644 index 00000000..a63179dd --- /dev/null +++ b/packages/errors/src/redact.ts @@ -0,0 +1,128 @@ +export const REDACTED = "[REDACTED]"; + +const SENSITIVE_KEY_RE = /secret|token|password|passwd|privatekey|credential|cookie|authorization|apikey|mnemonic|seedphrase/i; + +/** + * Evaluates whether an object property key contains sensitive terminology. + */ +export function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEY_RE.test(key.replace(/[^a-z0-9]/gi, "")); +} + +/** + * Scrubs known secret patterns from a string. + */ +export function scrubText(value: string, dynamicSecrets: ReadonlySet = new Set()): string { + let text = value + .replace(/(https?:\/\/)[^\s/@]+(?::[^\s/@]*)?@/gi, "$1[REDACTED]@") + .replace(/([?&](?:[^=&\s]*(?:secret|token|password|credential|api[_-]?key)[^=&\s]*)=)[^&#\s]*/gi, "$1[REDACTED]") + .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9+/_=.-]+/gi, "$1 [REDACTED]") + .replace(/((?:cookie|set-cookie)\s*[:=]\s*)[^\r\n;]+/gi, "$1[REDACTED]") + .replace(/\bS[A-Z2-7]{55}\b/g, REDACTED) + .replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g, REDACTED); + + for (const secret of dynamicSecrets) { + if (secret && secret.length >= 4) { + text = text.split(secret).join(REDACTED); + } + } + + return text; +} + +/** + * Recursively collects sensitive string values from an object graph. + */ +export function collectSecrets( + value: unknown, + secrets: Set, + seen: WeakSet = new WeakSet(), + depth = 0, +): void { + if (!value || typeof value !== "object" || depth > 32) return; + if (seen.has(value)) return; + seen.add(value); + + try { + for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) { + if (!("value" in descriptor)) continue; + const val = descriptor.value; + if (isSensitiveKey(key)) { + if (typeof val === "string" && val.length >= 4) { + secrets.add(val); + } else { + collectSecrets(val, secrets, seen, depth + 1); + } + } else { + collectSecrets(val, secrets, seen, depth + 1); + } + } + } catch { + return; + } +} + +/** + * Recursively redacts secrets and sensitive keys from any arbitrary value. + */ +export function redactSensitive( + value: T, + dynamicSecrets: ReadonlySet = new Set(), + seen: WeakSet = new WeakSet(), + depth = 0, +): unknown { + if (typeof value === "string") { + return scrubText(value, dynamicSecrets); + } + if (typeof value === "bigint") { + return value.toString(); + } + if (value === null || typeof value === "boolean" || typeof value === "number") { + return value; + } + if (value === undefined) { + return null; + } + if (typeof value !== "object") { + return `[${typeof value}]`; + } + if (depth > 32) { + return "[MaxDepth]"; + } + if (seen.has(value)) { + return "[Circular]"; + } + + seen.add(value); + try { + if (Array.isArray(value)) { + return value.map((item) => redactSensitive(item, dynamicSecrets, seen, depth + 1)); + } + + const descriptors = Object.getOwnPropertyDescriptors(value); + const output: Record = Object.create(null); + + if (value instanceof Error) { + output.name = typeof value.name === "string" ? scrubText(value.name, dynamicSecrets) : "Error"; + output.message = typeof value.message === "string" ? scrubText(value.message, dynamicSecrets) : ""; + if (typeof value.stack === "string") { + output.stack = scrubText(value.stack, dynamicSecrets); + } + } + + for (const [key, descriptor] of Object.entries(descriptors)) { + if (isSensitiveKey(key)) { + output[key] = REDACTED; + } else if ("value" in descriptor) { + output[key] = redactSensitive(descriptor.value, dynamicSecrets, seen, depth + 1); + } else { + output[key] = "[Accessor]"; + } + } + return output; + } catch { + return "[Unserializable]"; + } finally { + seen.delete(value); + } +} diff --git a/packages/errors/src/types.ts b/packages/errors/src/types.ts new file mode 100644 index 00000000..bbbf9a1a --- /dev/null +++ b/packages/errors/src/types.ts @@ -0,0 +1,59 @@ +/** + * Structured options passed to normalizeError. + */ +export interface NormalizeOptions { + code?: string | number; + retryable?: boolean; + context?: Record; + publicMessage?: string; + maxDepth?: number; +} + +/** + * Public serializable representation of an error suitable for snapshot-free assertions. + */ +export interface AssertableError { + name: string; + message: string; + code?: string | number; + retryable: boolean; + publicMessage: string; + context?: Record; + cause?: AssertableError; +} + +/** + * Operator diagnostic view containing technical context, scrubbed stack, and cause chain. + */ +export interface OperatorDiagnostics { + name: string; + message: string; + code?: string | number; + retryable: boolean; + stack?: string; + context?: Record; + causes?: Array<{ + name: string; + message: string; + code?: string | number; + stack?: string; + }>; +} + +/** + * Core interface describing a normalized error. + */ +export interface INormalizedError { + readonly name: string; + readonly message: string; + readonly code?: string | number; + readonly cause?: INormalizedError; + readonly stack?: string; + readonly retryable: boolean; + readonly context?: Record; + readonly publicMessage: string; + readonly raw?: unknown; + + toAssertable(): AssertableError; + toOperatorDiagnostics(): OperatorDiagnostics; +} diff --git a/packages/errors/tsconfig.json b/packages/errors/tsconfig.json new file mode 100644 index 00000000..a6f8b748 --- /dev/null +++ b/packages/errors/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"], + "noEmit": true + }, + "include": ["src"] +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 720e2ac2..9d61674b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -28,6 +28,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/errors": "workspace:*", "@sub-rosa/logging": "workspace:*", "@openzeppelin/relayer-plugin-channels": "^0.20.0", "@stellar/stellar-sdk": "^15.1.0", diff --git a/packages/sdk/src/mainnet-readiness.ts b/packages/sdk/src/mainnet-readiness.ts index 847f118b..f0df6a38 100644 --- a/packages/sdk/src/mainnet-readiness.ts +++ b/packages/sdk/src/mainnet-readiness.ts @@ -9,6 +9,7 @@ import { TransactionBuilder, } from "@stellar/stellar-sdk"; +import { getErrorMessage } from "@sub-rosa/errors"; import type { SubRosaClient } from "./client.js"; import { MAINNET_ARTIFACTS, @@ -356,7 +357,7 @@ export async function runMainnetReadiness( "rpc-reachable", "RPC reachable", "block", - err instanceof Error ? err.message : String(err), + getErrorMessage(err), ), ); } @@ -392,7 +393,7 @@ export async function runMainnetReadiness( "wasm-hash", "Artifact wasm hash", "block", - err instanceof Error ? err.message : String(err), + getErrorMessage(err), ), ); } @@ -427,7 +428,7 @@ export async function runMainnetReadiness( "settled-round", "Settled round proof", "block", - err instanceof Error ? err.message : String(err), + getErrorMessage(err), ), ); } @@ -474,7 +475,7 @@ export async function runMainnetReadiness( "contract-balance", "Contract escrow balance", "block", - err instanceof Error ? err.message : String(err), + getErrorMessage(err), ), ); } @@ -540,7 +541,7 @@ export async function runMainnetReadiness( id, label, "block", - err instanceof Error ? err.message : String(err), + getErrorMessage(err), ), ); } diff --git a/packages/sdk/src/preflight.ts b/packages/sdk/src/preflight.ts index 159407f2..d737915a 100644 --- a/packages/sdk/src/preflight.ts +++ b/packages/sdk/src/preflight.ts @@ -5,6 +5,7 @@ import { type Result, } from "@stellar/stellar-sdk/contract"; import { Errors as RoundContractErrors } from "@sub-rosa/round-bindings"; +import { getErrorMessage } from "@sub-rosa/errors"; import { SubRosaPreflightError } from "./errors.js"; export type PreflightOperation = @@ -132,8 +133,7 @@ export function classifyPreflightBuildError( return error; } - const message = - error instanceof Error ? error.message : "RPC simulation request failed"; + const message = getErrorMessage(error) || "RPC simulation request failed"; return new SubRosaPreflightError({ kind: "rpc_error", operation, diff --git a/packages/tlock/package.json b/packages/tlock/package.json index 4a90ebd9..50966687 100644 --- a/packages/tlock/package.json +++ b/packages/tlock/package.json @@ -28,6 +28,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/errors": "workspace:*", "@sub-rosa/time": "workspace:*", "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", diff --git a/packages/tlock/src/auditor-recovery-cli.ts b/packages/tlock/src/auditor-recovery-cli.ts index 9182a015..3f984fca 100644 --- a/packages/tlock/src/auditor-recovery-cli.ts +++ b/packages/tlock/src/auditor-recovery-cli.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 Sub Rosa contributors import { readFileSync } from "node:fs"; +import { getErrorMessage } from "@sub-rosa/errors"; import { openIdentity } from "./auditor.js"; import { fromHex, toHex } from "./commitment.js"; @@ -179,7 +180,7 @@ function recoverRows( identityUtf8: new TextDecoder().decode(plain), }; } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = getErrorMessage(error); return { label, error: message }; } }); @@ -242,7 +243,7 @@ export function runAuditorRecoveryCli(argv: string[], stdin = ""): CliRun { const rows = recoverRows(blobs, auditorSecret); return { exitCode: 0, output: { ok: true, source: "json", rows } }; } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = getErrorMessage(error); return { exitCode: 1, output: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f68a677..568f058c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@sub-rosa/agent': specifier: workspace:* version: link:../../services/agent + '@sub-rosa/errors': + specifier: workspace:* + version: link:../../packages/errors '@sub-rosa/sdk': specifier: workspace:* version: link:../../packages/sdk @@ -83,6 +86,18 @@ importers: specifier: ^6.3.5 version: 6.4.3(@types/node@25.9.1)(tsx@4.22.4) + packages/errors: + devDependencies: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + tsx: + specifier: ^4.22.4 + version: 4.22.4 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/logging: {} packages/round-bindings: @@ -112,6 +127,9 @@ importers: '@stellar/stellar-sdk': specifier: ^15.1.0 version: 15.1.0 + '@sub-rosa/errors': + specifier: workspace:* + version: link:../errors '@sub-rosa/logging': specifier: workspace:* version: link:../logging @@ -158,6 +176,9 @@ importers: '@noble/hashes': specifier: ^2.2.0 version: 2.2.0 + '@sub-rosa/errors': + specifier: workspace:* + version: link:../errors '@sub-rosa/time': specifier: workspace:* version: link:../time @@ -220,6 +241,9 @@ importers: '@stellar/stellar-sdk': specifier: ^15.1.0 version: 15.1.0 + '@sub-rosa/errors': + specifier: workspace:* + version: link:../../packages/errors '@sub-rosa/logging': specifier: workspace:* version: link:../../packages/logging @@ -295,6 +319,9 @@ importers: services/keeper: dependencies: + '@sub-rosa/errors': + specifier: workspace:* + version: link:../../packages/errors '@sub-rosa/logging': specifier: workspace:* version: link:../../packages/logging @@ -323,6 +350,9 @@ importers: services/receipt-cli: dependencies: + '@sub-rosa/errors': + specifier: workspace:* + version: link:../../packages/errors '@sub-rosa/logging': specifier: workspace:* version: link:../../packages/logging diff --git a/scripts/check-error-normalization.mjs b/scripts/check-error-normalization.mjs new file mode 100755 index 00000000..07ddb379 --- /dev/null +++ b/scripts/check-error-normalization.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { pathToFileURL } from "node:url"; +import { createLogger } from "../packages/logging/src/index.cjs"; + +const diagnostics = createLogger("scripts.check-error-normalization"); +const ROOT = new URL("..", import.meta.url).pathname; +const SCAN_ROOTS = ["packages", "services", "apps"]; + +const ALLOWED_FILES = new Set([ + "packages/errors/src/classify.ts", + "packages/errors/src/normalize.ts", + "packages/errors/src/redact.ts", + "packages/errors/src/index.cjs", +]); + +const PATTERNS = [ + { + name: "instanceof Error ternary", + re: /(? ${v.hint}`, + ); + } + process.exit(1); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(); +} diff --git a/scripts/check-error-normalization.test.mjs b/scripts/check-error-normalization.test.mjs new file mode 100644 index 00000000..b9308049 --- /dev/null +++ b/scripts/check-error-normalization.test.mjs @@ -0,0 +1,47 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { findViolations } from "./check-error-normalization.mjs"; + +describe("findViolations - check-error-normalization", () => { + it("allows packages/errors/src/normalize.ts", () => { + const hits = findViolations( + "const x = err instanceof Error ? err.message : String(err);", + "packages/errors/src/normalize.ts", + ); + assert.equal(hits.length, 0); + }); + + it("allows test files to perform assertions on errors", () => { + const hits = findViolations( + "const msg = e instanceof Error ? e.message : String(e);", + "packages/sdk/src/client.test.ts", + ); + assert.equal(hits.length, 0); + }); + + it("flags instanceof Error ternary in production code", () => { + const hits = findViolations( + "const msg = e instanceof Error ? e.message : String(e);", + "apps/web/src/hooks/useLiveRound.ts", + ); + assert.equal(hits.length >= 1, true); + assert.equal(hits[0].pattern, "instanceof Error ternary"); + }); + + it("flags raw String(error) coercion in production code", () => { + const hits = findViolations( + "return String(e);", + "services/keeper/src/keeper.ts", + ); + assert.equal(hits.length, 1); + assert.equal(hits[0].pattern, "raw String(error) coercion"); + }); + + it("allows proper use of getErrorMessage and normalizeError", () => { + const hits = findViolations( + "const message = getErrorMessage(error);\nconst normalized = normalizeError(error);", + "packages/sdk/src/preflight.ts", + ); + assert.equal(hits.length, 0); + }); +}); diff --git a/services/appraisal-api/package.json b/services/appraisal-api/package.json index 2bb7e81b..bab373dd 100644 --- a/services/appraisal-api/package.json +++ b/services/appraisal-api/package.json @@ -16,6 +16,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/errors": "workspace:*", "@sub-rosa/logging": "workspace:*", "@stellar/stellar-sdk": "^15.1.0", "@x402/core": "^2.14.0", diff --git a/services/appraisal-api/src/server.ts b/services/appraisal-api/src/server.ts index 2a391b30..7aa28d01 100644 --- a/services/appraisal-api/src/server.ts +++ b/services/appraisal-api/src/server.ts @@ -10,6 +10,7 @@ import http from "node:http"; +import { getErrorMessage } from "@sub-rosa/errors"; import { x402Facilitator } from "@x402/core/facilitator"; import { x402HTTPResourceServer, @@ -226,7 +227,7 @@ export async function buildAppraisalServer( }, ); } catch (err) { - send(res, 500, {}, { error: err instanceof Error ? err.message : String(err) }); + send(res, 500, {}, { error: getErrorMessage(err) }); } }); } diff --git a/services/keeper/package.json b/services/keeper/package.json index f75c0f2b..ca189a43 100644 --- a/services/keeper/package.json +++ b/services/keeper/package.json @@ -19,6 +19,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/errors": "workspace:*", "@sub-rosa/logging": "workspace:*", "@sub-rosa/sdk": "workspace:*", "@sub-rosa/time": "workspace:*", diff --git a/services/keeper/src/keeper.ts b/services/keeper/src/keeper.ts index 29706b50..b1aa3f14 100644 --- a/services/keeper/src/keeper.ts +++ b/services/keeper/src/keeper.ts @@ -16,6 +16,7 @@ // no mock — just the SDK over real RPC and the live Drand beacon. import type { SubRosaClient } from "@sub-rosa/sdk"; +import { getErrorMessage } from "@sub-rosa/errors"; import { openBid, fetchRoundSignature, type DrandClient } from "@sub-rosa/tlock"; import { compareRoundIds } from "./store.js"; import { @@ -63,7 +64,7 @@ export function errorName(e: unknown): string { try { return JSON.stringify(e); } catch { - return String(e); + return getErrorMessage(e); } } diff --git a/services/keeper/src/queue.ts b/services/keeper/src/queue.ts index a913b447..c27476c4 100644 --- a/services/keeper/src/queue.ts +++ b/services/keeper/src/queue.ts @@ -1,5 +1,6 @@ // Copyright (c) 2026 Sub Rosa contributors import { createLogger } from '@sub-rosa/logging'; +import { getErrorMessage } from "@sub-rosa/errors"; const diagnostics = createLogger("services.keeper.src.queue"); import { KeeperStore, normalizeRoundId } from "./store.js"; @@ -66,6 +67,6 @@ function main() { try { main(); } catch (error) { - diagnostics.error("error", `Error: ${error instanceof Error ? error.message : String(error)}`); + diagnostics.error("error", `Error: ${getErrorMessage(error)}`); process.exit(1); } diff --git a/services/keeper/src/status-server.ts b/services/keeper/src/status-server.ts index d892440c..13bb659c 100644 --- a/services/keeper/src/status-server.ts +++ b/services/keeper/src/status-server.ts @@ -1,5 +1,6 @@ // Copyright (c) 2026 Sub Rosa contributors import { createLogger, type Logger } from '@sub-rosa/logging'; +import { getErrorMessage } from "@sub-rosa/errors"; const diagnostics = createLogger("services.keeper.src.status-server"); import http from "node:http"; @@ -143,7 +144,7 @@ function healthzHandler( }, }; } catch (e) { - const detail = e instanceof Error ? e.message : String(e); + const detail = getErrorMessage(e); (src.logger ?? diagnostics).error("keeper-healthz-health-check-failed", "[keeper-healthz] health check failed:", { "detail_0": detail }); return { status: 503, @@ -201,10 +202,10 @@ export function createStatusServer(config: StatusServerConfig): http.Server { .then((body) => match.handler(url, body)) .then((r) => send(res, r.status, r.body)) .catch((e) => { - send(res, 500, { error: e instanceof Error ? e.message : String(e) }); + send(res, 500, { error: getErrorMessage(e) }); }); } catch (e) { - send(res, 500, { error: e instanceof Error ? e.message : String(e) }); + send(res, 500, { error: getErrorMessage(e) }); } }); diff --git a/services/keeper/src/status.ts b/services/keeper/src/status.ts index 25ad4d43..a030b16d 100644 --- a/services/keeper/src/status.ts +++ b/services/keeper/src/status.ts @@ -1,5 +1,6 @@ // Copyright (c) 2026 Sub Rosa contributors import { createLogger, type Logger } from '@sub-rosa/logging'; +import { getErrorMessage } from "@sub-rosa/errors"; const diagnostics = createLogger("services.keeper.src.status"); import type { SubRosaClient } from "@sub-rosa/sdk"; import { fetchRoundSignature, type DrandClient } from "@sub-rosa/tlock"; @@ -120,7 +121,7 @@ export async function buildRoundStatus( try { round = await reader.getRound(roundId); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); + const msg = getErrorMessage(e); const notFound = /RoundNotFound/i.test(msg); return { roundId: ridStr, @@ -290,11 +291,8 @@ export async function checkHealth( try { await reader.getRound(0n); } catch (e) { - // A valid health probe can legitimately return RoundNotFound; that still - // proves the RPC endpoint is reachable and returning well-formed errors. - const msg = e instanceof Error ? e.message : String(e); + const msg = getErrorMessage(e); if (/RoundNotFound|NotInitialized/i.test(msg)) { - // healthy-enough: reachable } else { rpc = "down"; logger.error("keeper-health-rpc-probe-failed", "[keeper-health] rpc probe failed:", { "msg_0": msg }); @@ -306,7 +304,7 @@ export async function checkHealth( await drand.chain().info(); } catch (e) { drandStatus = "down"; - const msg = e instanceof Error ? e.message : String(e); + const msg = getErrorMessage(e); logger.error("keeper-health-drand-probe-failed", "[keeper-health] drand probe failed:", { "msg_0": msg }); reasons.push("drand: unavailable"); } diff --git a/services/keeper/src/watch-loop.ts b/services/keeper/src/watch-loop.ts index c448db66..d2aa60f0 100644 --- a/services/keeper/src/watch-loop.ts +++ b/services/keeper/src/watch-loop.ts @@ -11,6 +11,7 @@ import type { SubRosaClient } from "@sub-rosa/sdk"; import type { DrandClient } from "@sub-rosa/tlock"; +import { getErrorMessage } from "@sub-rosa/errors"; import { resolveTimeContext, systemTime, type PartialTimeContext } from "@sub-rosa/time"; import { @@ -89,7 +90,7 @@ export async function runWatchLoop(params: RunWatchLoopParams): Promise { store.addRound(id, { contractId, network }); } } catch (e) { - log(`watch: failed to list/discover rounds: ${e instanceof Error ? e.message : String(e)}`); + log(`watch: failed to list/discover rounds: ${getErrorMessage(e)}`); } const activeRounds = store.listRounds().filter((r) => { @@ -143,15 +144,16 @@ export async function runWatchLoop(params: RunWatchLoopParams): Promise { ); } } catch (e) { - log(`[round ${roundId}] tick failed: ${e instanceof Error ? e.message : String(e)}`); + const errorMsg = getErrorMessage(e); + log(`[round ${roundId}] tick failed: ${errorMsg}`); settlementGuard.markRetryable( roundId, - e instanceof Error ? e.message : String(e), + errorMsg, ); const stored = store.getRound(roundId); store.updateRound(roundId, { retryCount: (stored?.retryCount ?? 0) + 1, - lastError: e instanceof Error ? e.message : String(e), + lastError: errorMsg, }); } } diff --git a/services/receipt-cli/package.json b/services/receipt-cli/package.json index 0edea34a..86817a86 100644 --- a/services/receipt-cli/package.json +++ b/services/receipt-cli/package.json @@ -19,6 +19,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/errors": "workspace:*", "@sub-rosa/logging": "workspace:*", "@sub-rosa/sdk": "workspace:*", "@sub-rosa/time": "workspace:*", diff --git a/services/receipt-cli/src/index.ts b/services/receipt-cli/src/index.ts index 034294f3..1147151c 100644 --- a/services/receipt-cli/src/index.ts +++ b/services/receipt-cli/src/index.ts @@ -7,6 +7,7 @@ const diagnostics = createLogger("services.receipt-cli.src.index"); import { readFileSync, writeFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { SubRosaClient, parseReceipt, serializeReceipt, verifyReceipt, redactReceipt } from "@sub-rosa/sdk"; +import { getErrorMessage } from "@sub-rosa/errors"; import { buildJsonOutput } from "./json-output.js"; function usage(): never { @@ -53,10 +54,11 @@ async function cmdVerify(path: string, jsonMode: boolean, artifactPath?: string) try { rawJson = readFileSync(path, "utf-8"); } catch (e) { + const message = getErrorMessage(e); if (jsonMode) { - writeData(JSON.stringify(buildJsonOutput(null, null, `Cannot read file: ${e}`), null, 2)); + writeData(JSON.stringify(buildJsonOutput(null, null, `Cannot read file: ${message}`), null, 2)); } else { - diagnostics.error("cannot-read", `Cannot read ${path}: ${e}`); + diagnostics.error("cannot-read", `Cannot read ${path}: ${message}`); } process.exit(1); } @@ -65,10 +67,11 @@ async function cmdVerify(path: string, jsonMode: boolean, artifactPath?: string) try { receipt = parseReceipt(rawJson); } catch (e) { + const message = getErrorMessage(e); if (jsonMode) { - writeData(JSON.stringify(buildJsonOutput(null, null, `Invalid JSON: ${e}`), null, 2)); + writeData(JSON.stringify(buildJsonOutput(null, null, `Invalid JSON: ${message}`), null, 2)); } else { - diagnostics.error("invalid-json", `Invalid JSON: ${e}`); + diagnostics.error("invalid-json", `Invalid JSON: ${message}`); } process.exit(1); } @@ -80,8 +83,8 @@ async function cmdVerify(path: string, jsonMode: boolean, artifactPath?: string) try { const data = readFileSync(artifactPath); computedChecksum = createHash("sha256").update(data).digest("hex"); - } catch (e: any) { - const message = `Cannot read artifact file: ${e.message}`; + } catch (e) { + const message = `Cannot read artifact file: ${getErrorMessage(e)}`; result.valid = false; result.issues.push({ severity: "error", @@ -156,7 +159,7 @@ async function cmdRedact(inputPath: string, outputPath?: string) { try { json = readFileSync(inputPath, "utf-8"); } catch (e) { - diagnostics.error("cannot-read-2", `Cannot read ${inputPath}: ${e}`); + diagnostics.error("cannot-read-2", `Cannot read ${inputPath}: ${getErrorMessage(e)}`); process.exit(1); } @@ -164,7 +167,7 @@ async function cmdRedact(inputPath: string, outputPath?: string) { try { receipt = parseReceipt(json); } catch (e) { - diagnostics.error("invalid-json-2", `Invalid JSON: ${e}`); + diagnostics.error("invalid-json-2", `Invalid JSON: ${getErrorMessage(e)}`); process.exit(1); } @@ -218,6 +221,6 @@ async function main() { } main().catch((e) => { - diagnostics.error("progress-8", e); + diagnostics.error("progress-8", getErrorMessage(e)); process.exit(1); });