@vasperacapital/vaspera-shared 0.1.0

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,185 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ createError,
4
+ createErrorResponse,
5
+ Errors,
6
+ VasperaApiError,
7
+ } from '../errors/factory.js';
8
+ import { ErrorCodes } from '../errors/codes.js';
9
+
10
+ describe('Error Handling', () => {
11
+ describe('createError', () => {
12
+ it('should create error with required fields', () => {
13
+ const error = createError(ErrorCodes.AUTH.REQUIRED);
14
+
15
+ expect(error.code).toBe('VPM-AUTH-001');
16
+ expect(error.message).toBe('Authentication required');
17
+ expect(error.timestamp).toBeDefined();
18
+ expect(error.requestId).toMatch(/^req_/);
19
+ expect(error.docUrl).toBe('https://docs.vaspera.pm/errors/VPM-AUTH-001');
20
+ });
21
+
22
+ it('should include details when provided', () => {
23
+ const error = createError(ErrorCodes.MCP.TOOL_NOT_FOUND, { tool: 'test_tool' });
24
+
25
+ expect(error.details).toEqual({ tool: 'test_tool' });
26
+ });
27
+
28
+ it('should use provided requestId', () => {
29
+ const error = createError(ErrorCodes.AUTH.REQUIRED, undefined, 'req_custom_123');
30
+
31
+ expect(error.requestId).toBe('req_custom_123');
32
+ });
33
+ });
34
+
35
+ describe('createErrorResponse', () => {
36
+ it('should create response with correct structure', () => {
37
+ const result = createErrorResponse(ErrorCodes.API_KEY.INVALID);
38
+
39
+ expect(result.status).toBe(401);
40
+ expect(result.response.success).toBe(false);
41
+ expect(result.response.error.code).toBe('VPM-API-KEY-002');
42
+ });
43
+
44
+ it('should use correct HTTP status codes', () => {
45
+ expect(createErrorResponse(ErrorCodes.AUTH.REQUIRED).status).toBe(401);
46
+ expect(createErrorResponse(ErrorCodes.AUTH.INSUFFICIENT_PERMISSIONS).status).toBe(403);
47
+ expect(createErrorResponse(ErrorCodes.MCP.TOOL_NOT_FOUND).status).toBe(404);
48
+ expect(createErrorResponse(ErrorCodes.API_KEY.RATE_LIMITED).status).toBe(429);
49
+ expect(createErrorResponse(ErrorCodes.SYSTEM.INTERNAL_ERROR).status).toBe(500);
50
+ });
51
+ });
52
+
53
+ describe('Errors helper functions', () => {
54
+ it('should create authRequired error', () => {
55
+ const result = Errors.authRequired();
56
+
57
+ expect(result.status).toBe(401);
58
+ expect(result.response.error.code).toBe('VPM-AUTH-001');
59
+ });
60
+
61
+ it('should create invalidApiKey error', () => {
62
+ const result = Errors.invalidApiKey();
63
+
64
+ expect(result.status).toBe(401);
65
+ expect(result.response.error.code).toBe('VPM-API-KEY-002');
66
+ });
67
+
68
+ it('should create rateLimited error with retryAfter', () => {
69
+ const result = Errors.rateLimited(60);
70
+
71
+ expect(result.status).toBe(429);
72
+ expect(result.response.error.details).toEqual({ retryAfterSeconds: 60 });
73
+ });
74
+
75
+ it('should create quotaExceeded error with usage details', () => {
76
+ const result = Errors.quotaExceeded(1000, 500);
77
+
78
+ expect(result.status).toBe(429);
79
+ expect(result.response.error.details?.currentUsage).toBe(1000);
80
+ expect(result.response.error.details?.limit).toBe(500);
81
+ expect(result.response.error.details?.resetDate).toBeDefined();
82
+ });
83
+
84
+ it('should create toolNotFound error with tool name', () => {
85
+ const result = Errors.toolNotFound('unknown_tool');
86
+
87
+ expect(result.status).toBe(404);
88
+ expect(result.response.error.details).toEqual({ tool: 'unknown_tool' });
89
+ });
90
+
91
+ it('should create toolExecutionFailed error', () => {
92
+ const result = Errors.toolExecutionFailed('test_tool', 'Connection failed');
93
+
94
+ expect(result.status).toBe(500);
95
+ expect(result.response.error.details).toEqual({
96
+ tool: 'test_tool',
97
+ reason: 'Connection failed',
98
+ });
99
+ });
100
+
101
+ it('should create integrationNotConnected error', () => {
102
+ const result = Errors.integrationNotConnected('github');
103
+
104
+ expect(result.status).toBe(400);
105
+ expect(result.response.error.details).toEqual({ provider: 'github' });
106
+ });
107
+
108
+ it('should create subscriptionRequired error', () => {
109
+ const result = Errors.subscriptionRequired('advanced_tools', 'pro');
110
+
111
+ expect(result.status).toBe(403);
112
+ expect(result.response.error.details).toEqual({
113
+ feature: 'advanced_tools',
114
+ requiredTier: 'pro',
115
+ });
116
+ });
117
+ });
118
+
119
+ describe('VasperaApiError', () => {
120
+ it('should create throwable error', () => {
121
+ const error = new VasperaApiError(ErrorCodes.VALIDATION.REQUIRED_FIELD, {
122
+ field: 'email',
123
+ });
124
+
125
+ expect(error).toBeInstanceOf(Error);
126
+ expect(error.name).toBe('VasperaApiError');
127
+ expect(error.message).toBe('Required field missing');
128
+ expect(error.errorDef).toBe(ErrorCodes.VALIDATION.REQUIRED_FIELD);
129
+ expect(error.details).toEqual({ field: 'email' });
130
+ });
131
+
132
+ it('should convert to response', () => {
133
+ const error = new VasperaApiError(ErrorCodes.MCP.TIMEOUT, {
134
+ tool: 'slow_tool',
135
+ timeoutMs: 30000,
136
+ });
137
+
138
+ const response = error.toResponse('req_test_123');
139
+
140
+ expect(response.status).toBe(504);
141
+ expect(response.response.error.requestId).toBe('req_test_123');
142
+ expect(response.response.error.details).toEqual({
143
+ tool: 'slow_tool',
144
+ timeoutMs: 30000,
145
+ });
146
+ });
147
+
148
+ it('should be catchable in try/catch', () => {
149
+ function throwError() {
150
+ throw new VasperaApiError(ErrorCodes.BILLING.NO_SUBSCRIPTION);
151
+ }
152
+
153
+ expect(throwError).toThrow(VasperaApiError);
154
+ expect(throwError).toThrow('No active subscription found');
155
+ });
156
+ });
157
+
158
+ describe('ErrorCodes structure', () => {
159
+ it('should have all error categories', () => {
160
+ expect(ErrorCodes.AUTH).toBeDefined();
161
+ expect(ErrorCodes.API_KEY).toBeDefined();
162
+ expect(ErrorCodes.MCP).toBeDefined();
163
+ expect(ErrorCodes.BILLING).toBeDefined();
164
+ expect(ErrorCodes.INTEGRATION).toBeDefined();
165
+ expect(ErrorCodes.VALIDATION).toBeDefined();
166
+ expect(ErrorCodes.SYSTEM).toBeDefined();
167
+ });
168
+
169
+ it('should have unique error codes', () => {
170
+ const allCodes = new Set<string>();
171
+
172
+ for (const category of Object.values(ErrorCodes)) {
173
+ for (const errorDef of Object.values(category)) {
174
+ if (allCodes.has(errorDef.code)) {
175
+ throw new Error(`Duplicate error code: ${errorDef.code}`);
176
+ }
177
+ allCodes.add(errorDef.code);
178
+ }
179
+ }
180
+
181
+ // We have at least this many unique codes
182
+ expect(allCodes.size).toBeGreaterThanOrEqual(25);
183
+ });
184
+ });
185
+ });
@@ -0,0 +1,213 @@
1
+ export interface ErrorDefinition {
2
+ code: string;
3
+ message: string;
4
+ httpStatus: number;
5
+ }
6
+
7
+ export const ErrorCodes = {
8
+ // Authentication Errors (VPM-AUTH-XXX)
9
+ AUTH: {
10
+ REQUIRED: {
11
+ code: 'VPM-AUTH-001',
12
+ message: 'Authentication required',
13
+ httpStatus: 401,
14
+ },
15
+ INVALID_TOKEN: {
16
+ code: 'VPM-AUTH-002',
17
+ message: 'Invalid or expired authentication token',
18
+ httpStatus: 401,
19
+ },
20
+ INSUFFICIENT_PERMISSIONS: {
21
+ code: 'VPM-AUTH-003',
22
+ message: 'Insufficient permissions for this action',
23
+ httpStatus: 403,
24
+ },
25
+ SESSION_EXPIRED: {
26
+ code: 'VPM-AUTH-004',
27
+ message: 'Session has expired, please login again',
28
+ httpStatus: 401,
29
+ },
30
+ MFA_REQUIRED: {
31
+ code: 'VPM-AUTH-005',
32
+ message: 'Multi-factor authentication required',
33
+ httpStatus: 403,
34
+ },
35
+ },
36
+
37
+ // API Key Errors (VPM-API-KEY-XXX)
38
+ API_KEY: {
39
+ REQUIRED: {
40
+ code: 'VPM-API-KEY-001',
41
+ message: 'API key is required',
42
+ httpStatus: 401,
43
+ },
44
+ INVALID: {
45
+ code: 'VPM-API-KEY-002',
46
+ message: 'Invalid API key',
47
+ httpStatus: 401,
48
+ },
49
+ REVOKED: {
50
+ code: 'VPM-API-KEY-003',
51
+ message: 'API key has been revoked',
52
+ httpStatus: 401,
53
+ },
54
+ EXPIRED: {
55
+ code: 'VPM-API-KEY-004',
56
+ message: 'API key has expired',
57
+ httpStatus: 401,
58
+ },
59
+ RATE_LIMITED: {
60
+ code: 'VPM-API-KEY-005',
61
+ message: 'API key rate limit exceeded',
62
+ httpStatus: 429,
63
+ },
64
+ QUOTA_EXCEEDED: {
65
+ code: 'VPM-API-KEY-006',
66
+ message: 'Monthly usage quota exceeded',
67
+ httpStatus: 429,
68
+ },
69
+ },
70
+
71
+ // MCP Tool Errors (VPM-MCP-XXX)
72
+ MCP: {
73
+ TOOL_NOT_FOUND: {
74
+ code: 'VPM-MCP-001',
75
+ message: 'Requested tool not found',
76
+ httpStatus: 404,
77
+ },
78
+ INVALID_ARGUMENTS: {
79
+ code: 'VPM-MCP-002',
80
+ message: 'Invalid tool arguments provided',
81
+ httpStatus: 400,
82
+ },
83
+ EXECUTION_FAILED: {
84
+ code: 'VPM-MCP-003',
85
+ message: 'Tool execution failed',
86
+ httpStatus: 500,
87
+ },
88
+ TIMEOUT: {
89
+ code: 'VPM-MCP-004',
90
+ message: 'Tool execution timed out',
91
+ httpStatus: 504,
92
+ },
93
+ LLM_ERROR: {
94
+ code: 'VPM-MCP-005',
95
+ message: 'AI model returned an error',
96
+ httpStatus: 502,
97
+ },
98
+ CONTEXT_TOO_LARGE: {
99
+ code: 'VPM-MCP-006',
100
+ message: 'Input context exceeds maximum size',
101
+ httpStatus: 413,
102
+ },
103
+ },
104
+
105
+ // Billing Errors (VPM-BILLING-XXX)
106
+ BILLING: {
107
+ NO_SUBSCRIPTION: {
108
+ code: 'VPM-BILLING-001',
109
+ message: 'No active subscription found',
110
+ httpStatus: 402,
111
+ },
112
+ SUBSCRIPTION_EXPIRED: {
113
+ code: 'VPM-BILLING-002',
114
+ message: 'Subscription has expired',
115
+ httpStatus: 402,
116
+ },
117
+ PAYMENT_FAILED: {
118
+ code: 'VPM-BILLING-003',
119
+ message: 'Payment processing failed',
120
+ httpStatus: 402,
121
+ },
122
+ FEATURE_NOT_INCLUDED: {
123
+ code: 'VPM-BILLING-004',
124
+ message: 'Feature not included in current plan',
125
+ httpStatus: 403,
126
+ },
127
+ UPGRADE_REQUIRED: {
128
+ code: 'VPM-BILLING-005',
129
+ message: 'Plan upgrade required for this action',
130
+ httpStatus: 403,
131
+ },
132
+ },
133
+
134
+ // Integration Errors (VPM-INT-XXX)
135
+ INTEGRATION: {
136
+ NOT_CONNECTED: {
137
+ code: 'VPM-INT-001',
138
+ message: 'Integration not connected',
139
+ httpStatus: 400,
140
+ },
141
+ TOKEN_EXPIRED: {
142
+ code: 'VPM-INT-002',
143
+ message: 'Integration token expired, reconnection required',
144
+ httpStatus: 401,
145
+ },
146
+ REFRESH_FAILED: {
147
+ code: 'VPM-INT-003',
148
+ message: 'Failed to refresh integration token',
149
+ httpStatus: 502,
150
+ },
151
+ API_ERROR: {
152
+ code: 'VPM-INT-004',
153
+ message: 'External integration API error',
154
+ httpStatus: 502,
155
+ },
156
+ RATE_LIMITED: {
157
+ code: 'VPM-INT-005',
158
+ message: 'Integration rate limit exceeded',
159
+ httpStatus: 429,
160
+ },
161
+ },
162
+
163
+ // Validation Errors (VPM-VAL-XXX)
164
+ VALIDATION: {
165
+ REQUIRED_FIELD: {
166
+ code: 'VPM-VAL-001',
167
+ message: 'Required field missing',
168
+ httpStatus: 400,
169
+ },
170
+ INVALID_FORMAT: {
171
+ code: 'VPM-VAL-002',
172
+ message: 'Invalid field format',
173
+ httpStatus: 400,
174
+ },
175
+ OUT_OF_RANGE: {
176
+ code: 'VPM-VAL-003',
177
+ message: 'Value out of allowed range',
178
+ httpStatus: 400,
179
+ },
180
+ INVALID_JSON: {
181
+ code: 'VPM-VAL-004',
182
+ message: 'Invalid JSON in request body',
183
+ httpStatus: 400,
184
+ },
185
+ },
186
+
187
+ // System Errors (VPM-SYS-XXX)
188
+ SYSTEM: {
189
+ INTERNAL_ERROR: {
190
+ code: 'VPM-SYS-001',
191
+ message: 'Internal server error',
192
+ httpStatus: 500,
193
+ },
194
+ DATABASE_ERROR: {
195
+ code: 'VPM-SYS-002',
196
+ message: 'Database operation failed',
197
+ httpStatus: 500,
198
+ },
199
+ SERVICE_UNAVAILABLE: {
200
+ code: 'VPM-SYS-003',
201
+ message: 'Service temporarily unavailable',
202
+ httpStatus: 503,
203
+ },
204
+ MAINTENANCE_MODE: {
205
+ code: 'VPM-SYS-004',
206
+ message: 'System is under maintenance',
207
+ httpStatus: 503,
208
+ },
209
+ },
210
+ } as const;
211
+
212
+ export type ErrorCodeType =
213
+ (typeof ErrorCodes)[keyof typeof ErrorCodes][keyof (typeof ErrorCodes)[keyof typeof ErrorCodes]];
@@ -0,0 +1,164 @@
1
+ import type { ErrorDefinition } from './codes.js';
2
+ import { ErrorCodes } from './codes.js';
3
+
4
+ export interface VasperaError {
5
+ code: string;
6
+ message: string;
7
+ details?: Record<string, unknown>;
8
+ timestamp: string;
9
+ requestId: string;
10
+ docUrl?: string;
11
+ }
12
+
13
+ export interface ErrorResponse {
14
+ success: false;
15
+ error: VasperaError;
16
+ }
17
+
18
+ function generateRequestId(): string {
19
+ return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
20
+ }
21
+
22
+ export function createError(
23
+ errorDef: ErrorDefinition,
24
+ details?: Record<string, unknown>,
25
+ requestId?: string
26
+ ): VasperaError {
27
+ return {
28
+ code: errorDef.code,
29
+ message: errorDef.message,
30
+ details,
31
+ timestamp: new Date().toISOString(),
32
+ requestId: requestId || generateRequestId(),
33
+ docUrl: `https://docs.vaspera.pm/errors/${errorDef.code}`,
34
+ };
35
+ }
36
+
37
+ export function createErrorResponse(
38
+ errorDef: ErrorDefinition,
39
+ details?: Record<string, unknown>,
40
+ requestId?: string
41
+ ): { response: ErrorResponse; status: number } {
42
+ return {
43
+ response: {
44
+ success: false,
45
+ error: createError(errorDef, details, requestId),
46
+ },
47
+ status: errorDef.httpStatus,
48
+ };
49
+ }
50
+
51
+ function getNextResetDate(): string {
52
+ const now = new Date();
53
+ const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
54
+ return nextMonth.toISOString();
55
+ }
56
+
57
+ // Pre-built error creators for common cases
58
+ export const Errors = {
59
+ authRequired: (requestId?: string) =>
60
+ createErrorResponse(ErrorCodes.AUTH.REQUIRED, undefined, requestId),
61
+
62
+ invalidApiKey: (requestId?: string) =>
63
+ createErrorResponse(ErrorCodes.API_KEY.INVALID, undefined, requestId),
64
+
65
+ apiKeyRevoked: (requestId?: string) =>
66
+ createErrorResponse(ErrorCodes.API_KEY.REVOKED, undefined, requestId),
67
+
68
+ apiKeyExpired: (requestId?: string) =>
69
+ createErrorResponse(ErrorCodes.API_KEY.EXPIRED, undefined, requestId),
70
+
71
+ rateLimited: (retryAfter: number, requestId?: string) =>
72
+ createErrorResponse(
73
+ ErrorCodes.API_KEY.RATE_LIMITED,
74
+ { retryAfterSeconds: retryAfter },
75
+ requestId
76
+ ),
77
+
78
+ quotaExceeded: (currentUsage: number, limit: number, requestId?: string) =>
79
+ createErrorResponse(
80
+ ErrorCodes.API_KEY.QUOTA_EXCEEDED,
81
+ { currentUsage, limit, resetDate: getNextResetDate() },
82
+ requestId
83
+ ),
84
+
85
+ toolNotFound: (toolName: string, requestId?: string) =>
86
+ createErrorResponse(
87
+ ErrorCodes.MCP.TOOL_NOT_FOUND,
88
+ { tool: toolName },
89
+ requestId
90
+ ),
91
+
92
+ toolExecutionFailed: (toolName: string, reason: string, requestId?: string) =>
93
+ createErrorResponse(
94
+ ErrorCodes.MCP.EXECUTION_FAILED,
95
+ { tool: toolName, reason },
96
+ requestId
97
+ ),
98
+
99
+ toolTimeout: (toolName: string, timeoutMs: number, requestId?: string) =>
100
+ createErrorResponse(
101
+ ErrorCodes.MCP.TIMEOUT,
102
+ { tool: toolName, timeoutMs },
103
+ requestId
104
+ ),
105
+
106
+ validationFailed: (field: string, reason: string, requestId?: string) =>
107
+ createErrorResponse(
108
+ ErrorCodes.VALIDATION.REQUIRED_FIELD,
109
+ { field, reason },
110
+ requestId
111
+ ),
112
+
113
+ invalidFormat: (field: string, expected: string, requestId?: string) =>
114
+ createErrorResponse(
115
+ ErrorCodes.VALIDATION.INVALID_FORMAT,
116
+ { field, expected },
117
+ requestId
118
+ ),
119
+
120
+ internalError: (message?: string, requestId?: string) =>
121
+ createErrorResponse(
122
+ ErrorCodes.SYSTEM.INTERNAL_ERROR,
123
+ message ? { message } : undefined,
124
+ requestId
125
+ ),
126
+
127
+ databaseError: (operation: string, requestId?: string) =>
128
+ createErrorResponse(
129
+ ErrorCodes.SYSTEM.DATABASE_ERROR,
130
+ { operation },
131
+ requestId
132
+ ),
133
+
134
+ integrationNotConnected: (provider: string, requestId?: string) =>
135
+ createErrorResponse(
136
+ ErrorCodes.INTEGRATION.NOT_CONNECTED,
137
+ { provider },
138
+ requestId
139
+ ),
140
+
141
+ subscriptionRequired: (feature: string, requiredTier: string, requestId?: string) =>
142
+ createErrorResponse(
143
+ ErrorCodes.BILLING.FEATURE_NOT_INCLUDED,
144
+ { feature, requiredTier },
145
+ requestId
146
+ ),
147
+ };
148
+
149
+ // Custom error class for throwing in API routes
150
+ export class VasperaApiError extends Error {
151
+ public readonly errorDef: ErrorDefinition;
152
+ public readonly details?: Record<string, unknown>;
153
+
154
+ constructor(errorDef: ErrorDefinition, details?: Record<string, unknown>) {
155
+ super(errorDef.message);
156
+ this.name = 'VasperaApiError';
157
+ this.errorDef = errorDef;
158
+ this.details = details;
159
+ }
160
+
161
+ toResponse(requestId?: string): { response: ErrorResponse; status: number } {
162
+ return createErrorResponse(this.errorDef, this.details, requestId);
163
+ }
164
+ }
@@ -0,0 +1,10 @@
1
+ export { ErrorCodes } from './codes.js';
2
+ export type { ErrorDefinition, ErrorCodeType } from './codes.js';
3
+
4
+ export {
5
+ createError,
6
+ createErrorResponse,
7
+ Errors,
8
+ VasperaApiError,
9
+ } from './factory.js';
10
+ export type { VasperaError, ErrorResponse } from './factory.js';
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ // Types
2
+ export * from './types/index.js';
3
+
4
+ // Errors
5
+ export * from './errors/index.js';
6
+
7
+ // Utilities
8
+ export * from './utils/index.js';