@outfitter/contracts 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,313 @@
1
+ import { TaggedErrorClass } from "better-result";
2
+ /**
3
+ * Error categories for classification, exit codes, and HTTP status mapping.
4
+ *
5
+ * Used for:
6
+ * - CLI exit code determination
7
+ * - HTTP status code mapping
8
+ * - Error grouping in logs and metrics
9
+ * - Client retry decisions (transient vs permanent)
10
+ */
11
+ type ErrorCategory = "validation" | "not_found" | "conflict" | "permission" | "timeout" | "rate_limit" | "network" | "internal" | "auth" | "cancelled";
12
+ /**
13
+ * Serialized error format for JSON transport.
14
+ */
15
+ interface SerializedError {
16
+ _tag: string;
17
+ category: ErrorCategory;
18
+ message: string;
19
+ context?: Record<string, unknown>;
20
+ }
21
+ declare const ValidationErrorBase: TaggedErrorClass<"ValidationError", {
22
+ message: string;
23
+ field?: string;
24
+ }>;
25
+ declare const AssertionErrorBase: TaggedErrorClass<"AssertionError", {
26
+ message: string;
27
+ }>;
28
+ declare const NotFoundErrorBase: TaggedErrorClass<"NotFoundError", {
29
+ message: string;
30
+ resourceType: string;
31
+ resourceId: string;
32
+ }>;
33
+ declare const ConflictErrorBase: TaggedErrorClass<"ConflictError", {
34
+ message: string;
35
+ context?: Record<string, unknown>;
36
+ }>;
37
+ declare const PermissionErrorBase: TaggedErrorClass<"PermissionError", {
38
+ message: string;
39
+ context?: Record<string, unknown>;
40
+ }>;
41
+ declare const TimeoutErrorBase: TaggedErrorClass<"TimeoutError", {
42
+ message: string;
43
+ operation: string;
44
+ timeoutMs: number;
45
+ }>;
46
+ declare const RateLimitErrorBase: TaggedErrorClass<"RateLimitError", {
47
+ message: string;
48
+ retryAfterSeconds?: number;
49
+ }>;
50
+ declare const NetworkErrorBase: TaggedErrorClass<"NetworkError", {
51
+ message: string;
52
+ context?: Record<string, unknown>;
53
+ }>;
54
+ declare const InternalErrorBase: TaggedErrorClass<"InternalError", {
55
+ message: string;
56
+ context?: Record<string, unknown>;
57
+ }>;
58
+ declare const AuthErrorBase: TaggedErrorClass<"AuthError", {
59
+ message: string;
60
+ reason?: "missing" | "invalid" | "expired";
61
+ }>;
62
+ declare const CancelledErrorBase: TaggedErrorClass<"CancelledError", {
63
+ message: string;
64
+ }>;
65
+ /**
66
+ * Input validation failed.
67
+ *
68
+ * @example
69
+ * ```typescript
70
+ * new ValidationError({ message: "Email format invalid", field: "email" });
71
+ * ```
72
+ */
73
+ declare class ValidationError extends ValidationErrorBase {
74
+ readonly category: "validation";
75
+ exitCode(): number;
76
+ statusCode(): number;
77
+ }
78
+ /**
79
+ * Assertion failed (invariant violation).
80
+ *
81
+ * Used by assertion utilities that return Result types instead of throwing.
82
+ * AssertionError indicates a programming bug — an invariant that should
83
+ * never be violated was broken. These are internal errors, not user input
84
+ * validation failures.
85
+ *
86
+ * **Category rationale**: Uses `internal` (not `validation`) because:
87
+ * - Assertions check **invariants** (programmer assumptions), not user input
88
+ * - A failed assertion means "this should be impossible if the code is correct"
89
+ * - User-facing validation uses {@link ValidationError} with helpful field info
90
+ * - HTTP 500 is correct: this is a server bug, not a client mistake
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * // In domain logic after validation has passed
95
+ * const result = assertDefined(cachedValue, "Cache should always have value after init");
96
+ * if (result.isErr()) {
97
+ * return result; // Propagate as internal error
98
+ * }
99
+ * ```
100
+ *
101
+ * @see ValidationError - For user input validation failures (HTTP 400)
102
+ */
103
+ declare class AssertionError extends AssertionErrorBase {
104
+ readonly category: "internal";
105
+ exitCode(): number;
106
+ statusCode(): number;
107
+ }
108
+ /**
109
+ * Requested resource not found.
110
+ *
111
+ * @example
112
+ * ```typescript
113
+ * new NotFoundError({ message: "note not found: abc123", resourceType: "note", resourceId: "abc123" });
114
+ * ```
115
+ */
116
+ declare class NotFoundError extends NotFoundErrorBase {
117
+ readonly category: "not_found";
118
+ exitCode(): number;
119
+ statusCode(): number;
120
+ }
121
+ /**
122
+ * State conflict (optimistic locking, concurrent modification).
123
+ *
124
+ * @example
125
+ * ```typescript
126
+ * new ConflictError({ message: "Resource was modified by another process" });
127
+ * ```
128
+ */
129
+ declare class ConflictError extends ConflictErrorBase {
130
+ readonly category: "conflict";
131
+ exitCode(): number;
132
+ statusCode(): number;
133
+ }
134
+ /**
135
+ * Authorization denied.
136
+ *
137
+ * @example
138
+ * ```typescript
139
+ * new PermissionError({ message: "Cannot delete read-only resource" });
140
+ * ```
141
+ */
142
+ declare class PermissionError extends PermissionErrorBase {
143
+ readonly category: "permission";
144
+ exitCode(): number;
145
+ statusCode(): number;
146
+ }
147
+ /**
148
+ * Operation timed out.
149
+ *
150
+ * @example
151
+ * ```typescript
152
+ * new TimeoutError({ message: "Database query timed out after 5000ms", operation: "Database query", timeoutMs: 5000 });
153
+ * ```
154
+ */
155
+ declare class TimeoutError extends TimeoutErrorBase {
156
+ readonly category: "timeout";
157
+ exitCode(): number;
158
+ statusCode(): number;
159
+ }
160
+ /**
161
+ * Rate limit exceeded.
162
+ *
163
+ * @example
164
+ * ```typescript
165
+ * new RateLimitError({ message: "Rate limit exceeded, retry after 60s", retryAfterSeconds: 60 });
166
+ * ```
167
+ */
168
+ declare class RateLimitError extends RateLimitErrorBase {
169
+ readonly category: "rate_limit";
170
+ exitCode(): number;
171
+ statusCode(): number;
172
+ }
173
+ /**
174
+ * Network/transport failure.
175
+ *
176
+ * @example
177
+ * ```typescript
178
+ * new NetworkError({ message: "Connection refused to api.example.com" });
179
+ * ```
180
+ */
181
+ declare class NetworkError extends NetworkErrorBase {
182
+ readonly category: "network";
183
+ exitCode(): number;
184
+ statusCode(): number;
185
+ }
186
+ /**
187
+ * Unexpected internal error.
188
+ *
189
+ * @example
190
+ * ```typescript
191
+ * new InternalError({ message: "Unexpected state in processor" });
192
+ * ```
193
+ */
194
+ declare class InternalError extends InternalErrorBase {
195
+ readonly category: "internal";
196
+ exitCode(): number;
197
+ statusCode(): number;
198
+ }
199
+ /**
200
+ * Authentication failed (missing or invalid credentials).
201
+ *
202
+ * @example
203
+ * ```typescript
204
+ * new AuthError({ message: "Invalid API key", reason: "invalid" });
205
+ * ```
206
+ */
207
+ declare class AuthError extends AuthErrorBase {
208
+ readonly category: "auth";
209
+ exitCode(): number;
210
+ statusCode(): number;
211
+ }
212
+ /**
213
+ * Operation cancelled by user or signal.
214
+ *
215
+ * @example
216
+ * ```typescript
217
+ * new CancelledError({ message: "Operation cancelled by user" });
218
+ * ```
219
+ */
220
+ declare class CancelledError extends CancelledErrorBase {
221
+ readonly category: "cancelled";
222
+ exitCode(): number;
223
+ statusCode(): number;
224
+ }
225
+ /**
226
+ * Union type of all concrete error class instances.
227
+ */
228
+ type AnyKitError = InstanceType<typeof ValidationError> | InstanceType<typeof AssertionError> | InstanceType<typeof NotFoundError> | InstanceType<typeof ConflictError> | InstanceType<typeof PermissionError> | InstanceType<typeof TimeoutError> | InstanceType<typeof RateLimitError> | InstanceType<typeof NetworkError> | InstanceType<typeof InternalError> | InstanceType<typeof AuthError> | InstanceType<typeof CancelledError>;
229
+ /**
230
+ * Type alias for backwards compatibility with handler signatures.
231
+ * Use AnyKitError for the union type.
232
+ */
233
+ type OutfitterError = AnyKitError;
234
+ import { Result } from "better-result";
235
+ import { z } from "zod";
236
+ /**
237
+ * Options for error serialization.
238
+ */
239
+ interface SerializeErrorOptions {
240
+ /** Include stack trace (default: false in production, true in development) */
241
+ includeStack?: boolean;
242
+ }
243
+ /**
244
+ * Serialize a OutfitterError to JSON-safe format.
245
+ *
246
+ * Strips stack traces in production, preserves in development.
247
+ * Automatically redacts sensitive values from context.
248
+ *
249
+ * @param error - The error to serialize
250
+ * @param options - Serialization options
251
+ * @returns JSON-safe serialized error
252
+ *
253
+ * @example
254
+ * ```typescript
255
+ * const serialized = serializeError(new NotFoundError("note", "abc123"));
256
+ * // { _tag: "NotFoundError", category: "not_found", message: "note not found: abc123", context: { resourceType: "note", resourceId: "abc123" } }
257
+ * ```
258
+ */
259
+ declare function serializeError(error: OutfitterError, options?: SerializeErrorOptions): SerializedError;
260
+ /**
261
+ * Deserialize error from JSON (e.g., from MCP response).
262
+ *
263
+ * Returns a typed OutfitterError subclass based on _tag.
264
+ *
265
+ * @param data - Serialized error data
266
+ * @returns Reconstructed OutfitterError instance
267
+ *
268
+ * @example
269
+ * ```typescript
270
+ * const error = deserializeError(jsonData);
271
+ * if (error._tag === "NotFoundError") {
272
+ * // TypeScript knows error.resourceType exists
273
+ * }
274
+ * ```
275
+ */
276
+ declare function deserializeError(data: SerializedError): OutfitterError;
277
+ /**
278
+ * Safely stringify any value to JSON.
279
+ *
280
+ * Handles circular references, BigInt, and other non-JSON-safe values.
281
+ * Applies redaction to sensitive values.
282
+ *
283
+ * @param value - Value to stringify
284
+ * @param space - Indentation (default: undefined for compact)
285
+ * @returns JSON string
286
+ *
287
+ * @example
288
+ * ```typescript
289
+ * const json = safeStringify({ apiKey: "sk-secret", data: "safe" });
290
+ * // '{"apiKey":"[REDACTED]","data":"safe"}'
291
+ * ```
292
+ */
293
+ declare function safeStringify(value: unknown, space?: number): string;
294
+ /**
295
+ * Safely parse JSON string with optional schema validation.
296
+ *
297
+ * Returns Result instead of throwing on invalid JSON.
298
+ *
299
+ * @typeParam T - Expected parsed type (or unknown if no schema)
300
+ * @param json - JSON string to parse
301
+ * @param schema - Optional Zod schema for validation
302
+ * @returns Result with parsed value or ValidationError
303
+ *
304
+ * @example
305
+ * ```typescript
306
+ * const result = safeParse<Config>('{"port": 3000}', ConfigSchema);
307
+ * if (result.isOk()) {
308
+ * const config = result.unwrap();
309
+ * }
310
+ * ```
311
+ */
312
+ declare function safeParse<T = unknown>(json: string, schema?: z.ZodType<T>): Result<T, ValidationError>;
313
+ export { serializeError, safeStringify, safeParse, deserializeError, SerializeErrorOptions };
@@ -0,0 +1,270 @@
1
+ // @bun
2
+ import {
3
+ DEFAULT_PATTERNS,
4
+ DEFAULT_SENSITIVE_KEYS,
5
+ createRedactor
6
+ } from "./redactor.js";
7
+ import {
8
+ AuthError,
9
+ CancelledError,
10
+ ConflictError,
11
+ InternalError,
12
+ NetworkError,
13
+ NotFoundError,
14
+ PermissionError,
15
+ RateLimitError,
16
+ TimeoutError,
17
+ ValidationError
18
+ } from "./errors.js";
19
+
20
+ // packages/contracts/src/serialization.ts
21
+ import { Result } from "better-result";
22
+ var errorRegistry = {
23
+ ValidationError,
24
+ NotFoundError,
25
+ ConflictError,
26
+ PermissionError,
27
+ TimeoutError,
28
+ RateLimitError,
29
+ NetworkError,
30
+ InternalError,
31
+ AuthError,
32
+ CancelledError
33
+ };
34
+ function isValidErrorTag(tag) {
35
+ return tag in errorRegistry;
36
+ }
37
+ function extractContext(error) {
38
+ const context = {};
39
+ switch (error._tag) {
40
+ case "ValidationError": {
41
+ const ve = error;
42
+ if (ve.field !== undefined) {
43
+ context["field"] = ve.field;
44
+ }
45
+ break;
46
+ }
47
+ case "NotFoundError": {
48
+ const nfe = error;
49
+ context["resourceType"] = nfe.resourceType;
50
+ context["resourceId"] = nfe.resourceId;
51
+ break;
52
+ }
53
+ case "TimeoutError": {
54
+ const te = error;
55
+ context["operation"] = te.operation;
56
+ context["timeoutMs"] = te.timeoutMs;
57
+ break;
58
+ }
59
+ case "RateLimitError": {
60
+ const rle = error;
61
+ if (rle.retryAfterSeconds !== undefined) {
62
+ context["retryAfterSeconds"] = rle.retryAfterSeconds;
63
+ }
64
+ break;
65
+ }
66
+ case "AuthError": {
67
+ const ae = error;
68
+ if (ae.reason !== undefined) {
69
+ context["reason"] = ae.reason;
70
+ }
71
+ break;
72
+ }
73
+ case "ConflictError":
74
+ case "PermissionError":
75
+ case "NetworkError":
76
+ case "InternalError": {
77
+ const ce = error;
78
+ if (ce.context !== undefined) {
79
+ Object.assign(context, ce.context);
80
+ }
81
+ break;
82
+ }
83
+ default:
84
+ break;
85
+ }
86
+ return Object.keys(context).length > 0 ? context : undefined;
87
+ }
88
+ function serializeError(error, options) {
89
+ const isProduction = process.env["NODE_ENV"] === "production";
90
+ const includeStack = options?.includeStack ?? !isProduction;
91
+ const context = extractContext(error);
92
+ const serialized = {
93
+ _tag: error._tag,
94
+ category: error.category,
95
+ message: error.message
96
+ };
97
+ if (context !== undefined) {
98
+ serialized.context = context;
99
+ }
100
+ if (includeStack && error.stack !== undefined) {
101
+ if (serialized.context === undefined) {
102
+ serialized.context = {};
103
+ }
104
+ serialized.context["stack"] = error.stack;
105
+ }
106
+ return serialized;
107
+ }
108
+ function deserializeError(data) {
109
+ const tag = data._tag;
110
+ if (!isValidErrorTag(tag)) {
111
+ const props = {
112
+ message: data.message
113
+ };
114
+ if (data.context !== undefined) {
115
+ props.context = data.context;
116
+ }
117
+ return new InternalError(props);
118
+ }
119
+ const context = data.context ?? {};
120
+ switch (tag) {
121
+ case "ValidationError": {
122
+ const props = {
123
+ message: data.message
124
+ };
125
+ const field = context["field"];
126
+ if (field !== undefined) {
127
+ props.field = field;
128
+ }
129
+ return new ValidationError(props);
130
+ }
131
+ case "NotFoundError":
132
+ return new NotFoundError({
133
+ message: data.message,
134
+ resourceType: context["resourceType"] ?? "unknown",
135
+ resourceId: context["resourceId"] ?? "unknown"
136
+ });
137
+ case "ConflictError": {
138
+ const props = {
139
+ message: data.message
140
+ };
141
+ if (Object.keys(context).length > 0) {
142
+ props.context = context;
143
+ }
144
+ return new ConflictError(props);
145
+ }
146
+ case "PermissionError": {
147
+ const props = {
148
+ message: data.message
149
+ };
150
+ if (Object.keys(context).length > 0) {
151
+ props.context = context;
152
+ }
153
+ return new PermissionError(props);
154
+ }
155
+ case "TimeoutError":
156
+ return new TimeoutError({
157
+ message: data.message,
158
+ operation: context["operation"] ?? "unknown",
159
+ timeoutMs: context["timeoutMs"] ?? 0
160
+ });
161
+ case "RateLimitError": {
162
+ const props = {
163
+ message: data.message
164
+ };
165
+ const retryAfter = context["retryAfterSeconds"];
166
+ if (retryAfter !== undefined) {
167
+ props.retryAfterSeconds = retryAfter;
168
+ }
169
+ return new RateLimitError(props);
170
+ }
171
+ case "NetworkError": {
172
+ const props = {
173
+ message: data.message
174
+ };
175
+ if (Object.keys(context).length > 0) {
176
+ props.context = context;
177
+ }
178
+ return new NetworkError(props);
179
+ }
180
+ case "InternalError": {
181
+ const props = {
182
+ message: data.message
183
+ };
184
+ if (Object.keys(context).length > 0) {
185
+ props.context = context;
186
+ }
187
+ return new InternalError(props);
188
+ }
189
+ case "AuthError": {
190
+ const props = {
191
+ message: data.message
192
+ };
193
+ const reason = context["reason"];
194
+ if (reason !== undefined) {
195
+ props.reason = reason;
196
+ }
197
+ return new AuthError(props);
198
+ }
199
+ case "CancelledError":
200
+ return new CancelledError({
201
+ message: data.message
202
+ });
203
+ default: {
204
+ const props = {
205
+ message: data.message
206
+ };
207
+ if (Object.keys(context).length > 0) {
208
+ props.context = context;
209
+ }
210
+ return new InternalError(props);
211
+ }
212
+ }
213
+ }
214
+ function safeStringify(value, space) {
215
+ const seen = new WeakSet;
216
+ const redactor = createRedactor({
217
+ patterns: [...DEFAULT_PATTERNS],
218
+ keys: [...DEFAULT_SENSITIVE_KEYS]
219
+ });
220
+ const replacer = (key, val) => {
221
+ if (typeof val === "bigint") {
222
+ return val.toString();
223
+ }
224
+ if (typeof val === "object" && val !== null) {
225
+ if (seen.has(val)) {
226
+ return "[Circular]";
227
+ }
228
+ seen.add(val);
229
+ }
230
+ if (key !== "" && redactor.isSensitiveKey(key) && val !== null && val !== undefined) {
231
+ return "[REDACTED]";
232
+ }
233
+ if (typeof val === "string") {
234
+ return redactor.redactString(val);
235
+ }
236
+ return val;
237
+ };
238
+ return JSON.stringify(value, replacer, space);
239
+ }
240
+ function safeParse(json, schema) {
241
+ let parsed;
242
+ try {
243
+ parsed = JSON.parse(json);
244
+ } catch (err) {
245
+ const errorMessage = err instanceof Error ? err.message : "Unknown parse error";
246
+ return Result.err(new ValidationError({
247
+ message: `JSON parse error: ${errorMessage}`
248
+ }));
249
+ }
250
+ if (schema === undefined) {
251
+ return Result.ok(parsed);
252
+ }
253
+ const parseResult = schema.safeParse(parsed);
254
+ if (parseResult.success) {
255
+ return Result.ok(parseResult.data);
256
+ }
257
+ const issues = parseResult.error.issues.map((issue) => {
258
+ const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
259
+ return `${path}: ${issue.message}`;
260
+ }).join("; ");
261
+ return Result.err(new ValidationError({
262
+ message: `Schema validation failed: ${issues}`
263
+ }));
264
+ }
265
+ export {
266
+ serializeError,
267
+ safeStringify,
268
+ safeParse,
269
+ deserializeError
270
+ };
@@ -0,0 +1,59 @@
1
+ import { TaggedErrorClass } from "better-result";
2
+ declare const ValidationErrorBase: TaggedErrorClass<"ValidationError", {
3
+ message: string;
4
+ field?: string;
5
+ }>;
6
+ /**
7
+ * Input validation failed.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * new ValidationError({ message: "Email format invalid", field: "email" });
12
+ * ```
13
+ */
14
+ declare class ValidationError extends ValidationErrorBase {
15
+ readonly category: "validation";
16
+ exitCode(): number;
17
+ statusCode(): number;
18
+ }
19
+ import { Result } from "better-result";
20
+ import { z } from "zod";
21
+ /**
22
+ * Create a validator function from a Zod schema.
23
+ *
24
+ * @typeParam T - The validated output type
25
+ * @param schema - Zod schema to validate against
26
+ * @returns A function that validates input and returns Result
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * const NoteSchema = z.object({
31
+ * id: z.string().uuid(),
32
+ * title: z.string().min(1),
33
+ * });
34
+ *
35
+ * const validateNote = createValidator(NoteSchema);
36
+ * const result = validateNote(input); // Result<Note, ValidationError>
37
+ * ```
38
+ */
39
+ declare function createValidator<T>(schema: z.ZodType<T>): (input: unknown) => Result<T, ValidationError>;
40
+ /**
41
+ * Validate input against a Zod schema.
42
+ *
43
+ * Standardized wrapper for Zod schemas that returns Result instead of throwing.
44
+ *
45
+ * @typeParam T - The validated output type
46
+ * @param schema - Zod schema to validate against
47
+ * @param input - Unknown input to validate
48
+ * @returns Result with validated data or ValidationError
49
+ *
50
+ * @example
51
+ * ```typescript
52
+ * const result = validateInput(NoteSchema, userInput);
53
+ * if (result.isErr()) {
54
+ * console.error(result.unwrapErr().message);
55
+ * }
56
+ * ```
57
+ */
58
+ declare function validateInput<T>(schema: z.ZodType<T>, input: unknown): Result<T, ValidationError>;
59
+ export { validateInput, createValidator };
@@ -0,0 +1,42 @@
1
+ // @bun
2
+ import {
3
+ ValidationError
4
+ } from "./errors.js";
5
+
6
+ // packages/contracts/src/validation.ts
7
+ import { Result } from "better-result";
8
+ function formatZodIssues(issues) {
9
+ return issues.map((issue) => {
10
+ const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
11
+ return `${path}: ${issue.message}`;
12
+ }).join("; ");
13
+ }
14
+ function extractField(issues) {
15
+ const firstIssue = issues[0];
16
+ if (firstIssue && firstIssue.path.length > 0) {
17
+ return firstIssue.path.join(".");
18
+ }
19
+ return;
20
+ }
21
+ function createValidator(schema) {
22
+ return (input) => {
23
+ return validateInput(schema, input);
24
+ };
25
+ }
26
+ function validateInput(schema, input) {
27
+ const parseResult = schema.safeParse(input);
28
+ if (parseResult.success) {
29
+ return Result.ok(parseResult.data);
30
+ }
31
+ const message = formatZodIssues(parseResult.error.issues);
32
+ const field = extractField(parseResult.error.issues);
33
+ const errorProps = { message };
34
+ if (field !== undefined) {
35
+ errorProps.field = field;
36
+ }
37
+ return Result.err(new ValidationError(errorProps));
38
+ }
39
+ export {
40
+ validateInput,
41
+ createValidator
42
+ };