Skip to content

Commit 0fe724f

Browse files
authored
feat: export error contract helpers (#396)
1 parent 037caf7 commit 0fe724f

File tree

5 files changed

+93
-62
lines changed

5 files changed

+93
-62
lines changed

src/__tests__/contracts-schema-public.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
import { test } from 'vitest';
22
import assert from 'node:assert/strict';
3+
import fs from 'node:fs';
4+
import path from 'node:path';
5+
import { AppError } from '../index.ts';
36
import {
7+
defaultHintForCode,
48
daemonCommandRequestSchema,
59
daemonRuntimeSchema,
610
centerOfRect,
711
jsonRpcRequestSchema,
812
leaseAllocateSchema,
913
leaseHeartbeatSchema,
1014
leaseReleaseSchema,
15+
normalizeError,
16+
type AppErrorCode,
1117
type Rect,
1218
type SnapshotNode,
1319
} from '../contracts.ts';
1420

21+
const invalidArgsCode = 'INVALID_ARGS' satisfies AppErrorCode;
1522
const rect = { x: 1, y: 2, width: 3, height: 4 } satisfies Rect;
1623
const node = {
1724
index: 0,
@@ -21,6 +28,16 @@ const node = {
2128
rect,
2229
} satisfies SnapshotNode;
2330

31+
test('public contracts error helpers do not load diagnostics module', () => {
32+
const errorsSource = fs.readFileSync(
33+
path.join(import.meta.dirname, '..', 'utils', 'errors.ts'),
34+
'utf8',
35+
);
36+
37+
assert.doesNotMatch(errorsSource, /['"]\.\/diagnostics\.ts['"]/);
38+
assert.doesNotMatch(errorsSource, /node:/);
39+
});
40+
2441
test('public contract schemas validate daemon requests and lease payloads', () => {
2542
const runtime = daemonRuntimeSchema.parse({
2643
platform: 'ios',
@@ -70,6 +87,17 @@ test('public contract schemas validate daemon requests and lease payloads', () =
7087
assert.equal(node.ref, 'e1');
7188
});
7289

90+
test('public contract exports normalize and hint app errors', () => {
91+
const normalized = normalizeError(new AppError(invalidArgsCode, 'Invalid command'));
92+
93+
assert.equal(normalized.code, invalidArgsCode);
94+
assert.equal(normalized.hint, defaultHintForCode(invalidArgsCode));
95+
assert.equal(
96+
defaultHintForCode('UNKNOWN'),
97+
'Retry with --debug and inspect diagnostics log for details.',
98+
);
99+
});
100+
73101
test('public contract schemas reject invalid payloads', () => {
74102
assert.throws(
75103
() =>

src/contracts.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
export type { AppErrorCode } from './utils/errors.ts';
2+
export { defaultHintForCode, normalizeError } from './utils/errors.ts';
3+
14
export type SessionRuntimeHints = {
25
platform?: 'ios' | 'android';
36
metroHost?: string;

src/utils/diagnostics.ts

Lines changed: 1 addition & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
33
import fs from 'node:fs';
44
import os from 'node:os';
55
import path from 'node:path';
6+
import { redactDiagnosticData } from './redaction.ts';
67

78
type DiagnosticLevel = 'info' | 'warn' | 'error' | 'debug';
89

@@ -33,11 +34,6 @@ type DiagnosticsScope = DiagnosticsScopeOptions & {
3334

3435
const diagnosticsStorage = new AsyncLocalStorage<DiagnosticsScope>();
3536

36-
const SENSITIVE_KEY_RE =
37-
/(token|secret|password|authorization|cookie|api[_-]?key|access[_-]?key|private[_-]?key)/i;
38-
const SENSITIVE_VALUE_RE =
39-
/(bearer\s+[a-z0-9._-]+|(?:api[_-]?key|token|secret|password)\s*[=:]\s*\S+)/i;
40-
4137
export function createRequestId(): string {
4238
return crypto.randomBytes(8).toString('hex');
4339
}
@@ -161,58 +157,6 @@ export function flushDiagnosticsToSessionFile(options: { force?: boolean } = {})
161157
}
162158
}
163159

164-
export function redactDiagnosticData<T>(input: T): T {
165-
return redactValue(input, new WeakSet<object>()) as T;
166-
}
167-
168-
function redactValue(value: unknown, seen: WeakSet<object>, keyHint?: string): unknown {
169-
if (value === null || value === undefined) return value;
170-
if (typeof value === 'string') return redactString(value, keyHint);
171-
if (typeof value !== 'object') return value;
172-
173-
if (seen.has(value as object)) return '[Circular]';
174-
seen.add(value as object);
175-
176-
if (Array.isArray(value)) {
177-
return value.map((entry) => redactValue(entry, seen));
178-
}
179-
180-
const output: Record<string, unknown> = {};
181-
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
182-
if (SENSITIVE_KEY_RE.test(key)) {
183-
output[key] = '[REDACTED]';
184-
continue;
185-
}
186-
output[key] = redactValue(entry, seen, key);
187-
}
188-
return output;
189-
}
190-
191-
function redactString(value: string, keyHint?: string): string {
192-
const trimmed = value.trim();
193-
if (!trimmed) return value;
194-
if (keyHint && SENSITIVE_KEY_RE.test(keyHint)) return '[REDACTED]';
195-
if (SENSITIVE_VALUE_RE.test(trimmed)) return '[REDACTED]';
196-
const maskedUrl = redactUrl(trimmed);
197-
if (maskedUrl) return maskedUrl;
198-
if (trimmed.length > 400) return `${trimmed.slice(0, 200)}...<truncated>`;
199-
return trimmed;
200-
}
201-
202-
function redactUrl(value: string): string | null {
203-
try {
204-
const parsed = new URL(value);
205-
if (parsed.search) parsed.search = '?REDACTED';
206-
if (parsed.username || parsed.password) {
207-
parsed.username = 'REDACTED';
208-
parsed.password = 'REDACTED';
209-
}
210-
return parsed.toString();
211-
} catch {
212-
return null;
213-
}
214-
}
215-
216160
function sanitizePathPart(value: string): string {
217161
return value.replace(/[^a-zA-Z0-9._-]/g, '_');
218162
}

src/utils/errors.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { redactDiagnosticData } from './diagnostics.ts';
1+
import { redactDiagnosticData } from './redaction.ts';
22

3-
type ErrorCode =
3+
export type AppErrorCode =
44
| 'INVALID_ARGS'
55
| 'DEVICE_NOT_FOUND'
66
| 'TOOL_MISSING'
@@ -28,11 +28,11 @@ export type NormalizedError = {
2828
};
2929

3030
export class AppError extends Error {
31-
code: ErrorCode;
31+
code: AppErrorCode;
3232
details?: AppErrorDetails;
3333
cause?: unknown;
3434

35-
constructor(code: ErrorCode, message: string, details?: AppErrorDetails, cause?: unknown) {
35+
constructor(code: AppErrorCode, message: string, details?: AppErrorDetails, cause?: unknown) {
3636
super(message);
3737
this.code = code;
3838
this.details = details;
@@ -115,7 +115,7 @@ function stripDiagnosticMeta(
115115
return Object.keys(output).length > 0 ? output : undefined;
116116
}
117117

118-
function defaultHintForCode(code: string): string | undefined {
118+
export function defaultHintForCode(code: string): string | undefined {
119119
switch (code) {
120120
case 'INVALID_ARGS':
121121
return 'Check command arguments and run --help for usage examples.';

src/utils/redaction.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
const SENSITIVE_KEY_RE =
2+
/(token|secret|password|authorization|cookie|api[_-]?key|access[_-]?key|private[_-]?key)/i;
3+
const SENSITIVE_VALUE_RE =
4+
/(bearer\s+[a-z0-9._-]+|(?:api[_-]?key|token|secret|password)\s*[=:]\s*\S+)/i;
5+
6+
export function redactDiagnosticData<T>(input: T): T {
7+
return redactValue(input, new WeakSet<object>()) as T;
8+
}
9+
10+
function redactValue(value: unknown, seen: WeakSet<object>, keyHint?: string): unknown {
11+
if (value === null || value === undefined) return value;
12+
if (typeof value === 'string') return redactString(value, keyHint);
13+
if (typeof value !== 'object') return value;
14+
15+
if (seen.has(value as object)) return '[Circular]';
16+
seen.add(value as object);
17+
18+
if (Array.isArray(value)) {
19+
return value.map((entry) => redactValue(entry, seen));
20+
}
21+
22+
const output: Record<string, unknown> = {};
23+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
24+
if (SENSITIVE_KEY_RE.test(key)) {
25+
output[key] = '[REDACTED]';
26+
continue;
27+
}
28+
output[key] = redactValue(entry, seen, key);
29+
}
30+
return output;
31+
}
32+
33+
function redactString(value: string, keyHint?: string): string {
34+
const trimmed = value.trim();
35+
if (!trimmed) return value;
36+
if (keyHint && SENSITIVE_KEY_RE.test(keyHint)) return '[REDACTED]';
37+
if (SENSITIVE_VALUE_RE.test(trimmed)) return '[REDACTED]';
38+
const maskedUrl = redactUrl(trimmed);
39+
if (maskedUrl) return maskedUrl;
40+
if (trimmed.length > 400) return `${trimmed.slice(0, 200)}...<truncated>`;
41+
return trimmed;
42+
}
43+
44+
function redactUrl(value: string): string | null {
45+
try {
46+
const parsed = new URL(value);
47+
if (parsed.search) parsed.search = '?REDACTED';
48+
if (parsed.username || parsed.password) {
49+
parsed.username = 'REDACTED';
50+
parsed.password = 'REDACTED';
51+
}
52+
return parsed.toString();
53+
} catch {
54+
return null;
55+
}
56+
}

0 commit comments

Comments
 (0)