diff --git a/src/__tests__/contracts-schema-public.test.ts b/src/__tests__/contracts-schema-public.test.ts index a83d4fb0..e24232e7 100644 --- a/src/__tests__/contracts-schema-public.test.ts +++ b/src/__tests__/contracts-schema-public.test.ts @@ -1,6 +1,10 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { AppError } from '../index.ts'; import { + defaultHintForCode, daemonCommandRequestSchema, daemonRuntimeSchema, centerOfRect, @@ -8,10 +12,13 @@ import { leaseAllocateSchema, leaseHeartbeatSchema, leaseReleaseSchema, + normalizeError, + type AppErrorCode, type Rect, type SnapshotNode, } from '../contracts.ts'; +const invalidArgsCode = 'INVALID_ARGS' satisfies AppErrorCode; const rect = { x: 1, y: 2, width: 3, height: 4 } satisfies Rect; const node = { index: 0, @@ -21,6 +28,16 @@ const node = { rect, } satisfies SnapshotNode; +test('public contracts error helpers do not load diagnostics module', () => { + const errorsSource = fs.readFileSync( + path.join(import.meta.dirname, '..', 'utils', 'errors.ts'), + 'utf8', + ); + + assert.doesNotMatch(errorsSource, /['"]\.\/diagnostics\.ts['"]/); + assert.doesNotMatch(errorsSource, /node:/); +}); + test('public contract schemas validate daemon requests and lease payloads', () => { const runtime = daemonRuntimeSchema.parse({ platform: 'ios', @@ -70,6 +87,17 @@ test('public contract schemas validate daemon requests and lease payloads', () = assert.equal(node.ref, 'e1'); }); +test('public contract exports normalize and hint app errors', () => { + const normalized = normalizeError(new AppError(invalidArgsCode, 'Invalid command')); + + assert.equal(normalized.code, invalidArgsCode); + assert.equal(normalized.hint, defaultHintForCode(invalidArgsCode)); + assert.equal( + defaultHintForCode('UNKNOWN'), + 'Retry with --debug and inspect diagnostics log for details.', + ); +}); + test('public contract schemas reject invalid payloads', () => { assert.throws( () => diff --git a/src/contracts.ts b/src/contracts.ts index 9ed249d2..47ea688d 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -1,3 +1,6 @@ +export type { AppErrorCode } from './utils/errors.ts'; +export { defaultHintForCode, normalizeError } from './utils/errors.ts'; + export type SessionRuntimeHints = { platform?: 'ios' | 'android'; metroHost?: string; diff --git a/src/utils/diagnostics.ts b/src/utils/diagnostics.ts index ee85063a..eedeb303 100644 --- a/src/utils/diagnostics.ts +++ b/src/utils/diagnostics.ts @@ -3,6 +3,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { redactDiagnosticData } from './redaction.ts'; type DiagnosticLevel = 'info' | 'warn' | 'error' | 'debug'; @@ -33,11 +34,6 @@ type DiagnosticsScope = DiagnosticsScopeOptions & { const diagnosticsStorage = new AsyncLocalStorage(); -const SENSITIVE_KEY_RE = - /(token|secret|password|authorization|cookie|api[_-]?key|access[_-]?key|private[_-]?key)/i; -const SENSITIVE_VALUE_RE = - /(bearer\s+[a-z0-9._-]+|(?:api[_-]?key|token|secret|password)\s*[=:]\s*\S+)/i; - export function createRequestId(): string { return crypto.randomBytes(8).toString('hex'); } @@ -161,58 +157,6 @@ export function flushDiagnosticsToSessionFile(options: { force?: boolean } = {}) } } -export function redactDiagnosticData(input: T): T { - return redactValue(input, new WeakSet()) as T; -} - -function redactValue(value: unknown, seen: WeakSet, keyHint?: string): unknown { - if (value === null || value === undefined) return value; - if (typeof value === 'string') return redactString(value, keyHint); - if (typeof value !== 'object') return value; - - if (seen.has(value as object)) return '[Circular]'; - seen.add(value as object); - - if (Array.isArray(value)) { - return value.map((entry) => redactValue(entry, seen)); - } - - const output: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - if (SENSITIVE_KEY_RE.test(key)) { - output[key] = '[REDACTED]'; - continue; - } - output[key] = redactValue(entry, seen, key); - } - return output; -} - -function redactString(value: string, keyHint?: string): string { - const trimmed = value.trim(); - if (!trimmed) return value; - if (keyHint && SENSITIVE_KEY_RE.test(keyHint)) return '[REDACTED]'; - if (SENSITIVE_VALUE_RE.test(trimmed)) return '[REDACTED]'; - const maskedUrl = redactUrl(trimmed); - if (maskedUrl) return maskedUrl; - if (trimmed.length > 400) return `${trimmed.slice(0, 200)}...`; - return trimmed; -} - -function redactUrl(value: string): string | null { - try { - const parsed = new URL(value); - if (parsed.search) parsed.search = '?REDACTED'; - if (parsed.username || parsed.password) { - parsed.username = 'REDACTED'; - parsed.password = 'REDACTED'; - } - return parsed.toString(); - } catch { - return null; - } -} - function sanitizePathPart(value: string): string { return value.replace(/[^a-zA-Z0-9._-]/g, '_'); } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index ad17ff96..5f02455e 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -1,6 +1,6 @@ -import { redactDiagnosticData } from './diagnostics.ts'; +import { redactDiagnosticData } from './redaction.ts'; -type ErrorCode = +export type AppErrorCode = | 'INVALID_ARGS' | 'DEVICE_NOT_FOUND' | 'TOOL_MISSING' @@ -28,11 +28,11 @@ export type NormalizedError = { }; export class AppError extends Error { - code: ErrorCode; + code: AppErrorCode; details?: AppErrorDetails; cause?: unknown; - constructor(code: ErrorCode, message: string, details?: AppErrorDetails, cause?: unknown) { + constructor(code: AppErrorCode, message: string, details?: AppErrorDetails, cause?: unknown) { super(message); this.code = code; this.details = details; @@ -115,7 +115,7 @@ function stripDiagnosticMeta( return Object.keys(output).length > 0 ? output : undefined; } -function defaultHintForCode(code: string): string | undefined { +export function defaultHintForCode(code: string): string | undefined { switch (code) { case 'INVALID_ARGS': return 'Check command arguments and run --help for usage examples.'; diff --git a/src/utils/redaction.ts b/src/utils/redaction.ts new file mode 100644 index 00000000..3a2bc394 --- /dev/null +++ b/src/utils/redaction.ts @@ -0,0 +1,56 @@ +const SENSITIVE_KEY_RE = + /(token|secret|password|authorization|cookie|api[_-]?key|access[_-]?key|private[_-]?key)/i; +const SENSITIVE_VALUE_RE = + /(bearer\s+[a-z0-9._-]+|(?:api[_-]?key|token|secret|password)\s*[=:]\s*\S+)/i; + +export function redactDiagnosticData(input: T): T { + return redactValue(input, new WeakSet()) as T; +} + +function redactValue(value: unknown, seen: WeakSet, keyHint?: string): unknown { + if (value === null || value === undefined) return value; + if (typeof value === 'string') return redactString(value, keyHint); + if (typeof value !== 'object') return value; + + if (seen.has(value as object)) return '[Circular]'; + seen.add(value as object); + + if (Array.isArray(value)) { + return value.map((entry) => redactValue(entry, seen)); + } + + const output: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + if (SENSITIVE_KEY_RE.test(key)) { + output[key] = '[REDACTED]'; + continue; + } + output[key] = redactValue(entry, seen, key); + } + return output; +} + +function redactString(value: string, keyHint?: string): string { + const trimmed = value.trim(); + if (!trimmed) return value; + if (keyHint && SENSITIVE_KEY_RE.test(keyHint)) return '[REDACTED]'; + if (SENSITIVE_VALUE_RE.test(trimmed)) return '[REDACTED]'; + const maskedUrl = redactUrl(trimmed); + if (maskedUrl) return maskedUrl; + if (trimmed.length > 400) return `${trimmed.slice(0, 200)}...`; + return trimmed; +} + +function redactUrl(value: string): string | null { + try { + const parsed = new URL(value); + if (parsed.search) parsed.search = '?REDACTED'; + if (parsed.username || parsed.password) { + parsed.username = 'REDACTED'; + parsed.password = 'REDACTED'; + } + return parsed.toString(); + } catch { + return null; + } +}