@memberjunction/server 5.7.0 → 5.9.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.
Files changed (78) hide show
  1. package/README.md +1 -0
  2. package/dist/agents/skip-agent.d.ts +15 -1
  3. package/dist/agents/skip-agent.d.ts.map +1 -1
  4. package/dist/agents/skip-agent.js +78 -36
  5. package/dist/agents/skip-agent.js.map +1 -1
  6. package/dist/agents/skip-sdk.d.ts.map +1 -1
  7. package/dist/agents/skip-sdk.js +12 -0
  8. package/dist/agents/skip-sdk.js.map +1 -1
  9. package/dist/apolloServer/index.d.ts +10 -2
  10. package/dist/apolloServer/index.d.ts.map +1 -1
  11. package/dist/apolloServer/index.js +22 -8
  12. package/dist/apolloServer/index.js.map +1 -1
  13. package/dist/config.d.ts +125 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +23 -0
  16. package/dist/config.js.map +1 -1
  17. package/dist/context.d.ts +17 -0
  18. package/dist/context.d.ts.map +1 -1
  19. package/dist/context.js +144 -62
  20. package/dist/context.js.map +1 -1
  21. package/dist/generated/generated.d.ts +607 -116
  22. package/dist/generated/generated.d.ts.map +1 -1
  23. package/dist/generated/generated.js +3542 -775
  24. package/dist/generated/generated.js.map +1 -1
  25. package/dist/generic/CacheInvalidationResolver.d.ts +32 -0
  26. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -0
  27. package/dist/generic/CacheInvalidationResolver.js +80 -0
  28. package/dist/generic/CacheInvalidationResolver.js.map +1 -0
  29. package/dist/generic/PubSubManager.d.ts +27 -0
  30. package/dist/generic/PubSubManager.d.ts.map +1 -0
  31. package/dist/generic/PubSubManager.js +42 -0
  32. package/dist/generic/PubSubManager.js.map +1 -0
  33. package/dist/generic/ResolverBase.d.ts +14 -0
  34. package/dist/generic/ResolverBase.d.ts.map +1 -1
  35. package/dist/generic/ResolverBase.js +50 -0
  36. package/dist/generic/ResolverBase.js.map +1 -1
  37. package/dist/hooks.d.ts +65 -0
  38. package/dist/hooks.d.ts.map +1 -0
  39. package/dist/hooks.js +14 -0
  40. package/dist/hooks.js.map +1 -0
  41. package/dist/index.d.ts +6 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +176 -45
  44. package/dist/index.js.map +1 -1
  45. package/dist/multiTenancy/index.d.ts +47 -0
  46. package/dist/multiTenancy/index.d.ts.map +1 -0
  47. package/dist/multiTenancy/index.js +152 -0
  48. package/dist/multiTenancy/index.js.map +1 -0
  49. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +123 -0
  50. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -0
  51. package/dist/resolvers/IntegrationDiscoveryResolver.js +624 -0
  52. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -0
  53. package/dist/rest/RESTEndpointHandler.d.ts +3 -1
  54. package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
  55. package/dist/rest/RESTEndpointHandler.js +14 -33
  56. package/dist/rest/RESTEndpointHandler.js.map +1 -1
  57. package/dist/types.d.ts +9 -0
  58. package/dist/types.d.ts.map +1 -1
  59. package/dist/types.js.map +1 -1
  60. package/package.json +61 -57
  61. package/src/__tests__/multiTenancy.security.test.ts +334 -0
  62. package/src/__tests__/multiTenancy.test.ts +225 -0
  63. package/src/__tests__/unifiedAuth.test.ts +416 -0
  64. package/src/agents/skip-agent.ts +87 -34
  65. package/src/agents/skip-sdk.ts +13 -0
  66. package/src/apolloServer/index.ts +32 -16
  67. package/src/config.ts +25 -0
  68. package/src/context.ts +205 -98
  69. package/src/generated/generated.ts +2334 -430
  70. package/src/generic/CacheInvalidationResolver.ts +66 -0
  71. package/src/generic/PubSubManager.ts +47 -0
  72. package/src/generic/ResolverBase.ts +53 -0
  73. package/src/hooks.ts +77 -0
  74. package/src/index.ts +203 -49
  75. package/src/multiTenancy/index.ts +183 -0
  76. package/src/resolvers/IntegrationDiscoveryResolver.ts +584 -0
  77. package/src/rest/RESTEndpointHandler.ts +23 -42
  78. package/src/types.ts +10 -0
@@ -0,0 +1,416 @@
1
+ /**
2
+ * Tests for the unified auth middleware (createUnifiedAuthMiddleware).
3
+ *
4
+ * Verifies: OPTIONS passthrough, token handling, userPayload attachment,
5
+ * 401 responses with proper error codes, and backward-compat properties.
6
+ *
7
+ * Because `createUnifiedAuthMiddleware` and `getUserPayload` live in the same
8
+ * module (context.ts), vi.mock cannot intercept the intra-module call.
9
+ * Instead we mock the LOWER-LEVEL dependencies that `getUserPayload` calls.
10
+ */
11
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
12
+
13
+ // ─── Hoisted mocks (available inside vi.mock factories) ─────────────────────
14
+ const {
15
+ MockTokenExpiredError,
16
+ mockGetReadOnlyDS,
17
+ mockGetReadWriteDS,
18
+ mockJwtDecode,
19
+ mockJwtVerify,
20
+ mockVerifyUserRecord,
21
+ mockExtractUserInfo,
22
+ mockGetValidationOptions,
23
+ mockGetSigningKeys,
24
+ mockGetByIssuer,
25
+ } = vi.hoisted(() => {
26
+ class _MockTokenExpiredError extends Error {
27
+ constructor(public expiryDate: Date) {
28
+ super('Token expired');
29
+ this.name = 'TokenExpiredError';
30
+ }
31
+ }
32
+
33
+ return {
34
+ MockTokenExpiredError: _MockTokenExpiredError,
35
+ mockGetReadOnlyDS: vi.fn(),
36
+ mockGetReadWriteDS: vi.fn(),
37
+ mockJwtDecode: vi.fn(),
38
+ mockJwtVerify: vi.fn(),
39
+ mockVerifyUserRecord: vi.fn(),
40
+ mockExtractUserInfo: vi.fn(),
41
+ mockGetValidationOptions: vi.fn(),
42
+ mockGetSigningKeys: vi.fn(),
43
+ mockGetByIssuer: vi.fn(),
44
+ };
45
+ });
46
+
47
+ // ─── Module mocks ───────────────────────────────────────────────────────────
48
+
49
+ vi.mock('../util.js', () => ({
50
+ GetReadOnlyDataSource: mockGetReadOnlyDS,
51
+ GetReadWriteDataSource: mockGetReadWriteDS,
52
+ }));
53
+
54
+ vi.mock('jsonwebtoken', () => ({
55
+ default: {
56
+ decode: mockJwtDecode,
57
+ verify: mockJwtVerify,
58
+ },
59
+ }));
60
+
61
+ vi.mock('../auth/index.js', () => ({
62
+ getSigningKeys: mockGetSigningKeys,
63
+ getSystemUser: vi.fn(),
64
+ getValidationOptions: mockGetValidationOptions,
65
+ verifyUserRecord: mockVerifyUserRecord,
66
+ extractUserInfoFromPayload: mockExtractUserInfo,
67
+ TokenExpiredError: MockTokenExpiredError,
68
+ }));
69
+
70
+ vi.mock('../cache.js', () => {
71
+ const map = new Map<string, boolean>();
72
+ return { authCache: map };
73
+ });
74
+
75
+ vi.mock('../config.js', () => ({
76
+ configInfo: {
77
+ baseUrl: 'http://localhost',
78
+ graphqlPort: 4001,
79
+ graphqlRootPath: '/',
80
+ databaseSettings: { metadataCacheRefreshInterval: 0 },
81
+ },
82
+ userEmailMap: {},
83
+ apiKey: 'test-api-key',
84
+ mj_core_schema: '__mj',
85
+ }));
86
+
87
+ vi.mock('../auth/AuthProviderFactory.js', () => ({
88
+ AuthProviderFactory: {
89
+ getInstance: () => ({ getByIssuer: mockGetByIssuer }),
90
+ },
91
+ }));
92
+
93
+ vi.mock('@memberjunction/api-keys', () => ({
94
+ GetAPIKeyEngine: vi.fn(),
95
+ }));
96
+
97
+ import { createUnifiedAuthMiddleware } from '../context.js';
98
+ import type { Request, Response, NextFunction } from 'express';
99
+ import type { DataSourceInfo } from '../types.js';
100
+
101
+ // ─── Helpers ────────────────────────────────────────────────────────────────
102
+
103
+ function makeMockReq(overrides: Partial<Request> = {}): Request {
104
+ return {
105
+ method: 'POST',
106
+ headers: {},
107
+ path: '/',
108
+ url: '/',
109
+ ip: '127.0.0.1',
110
+ socket: { remoteAddress: '127.0.0.1' },
111
+ ...overrides,
112
+ } as unknown as Request;
113
+ }
114
+
115
+ function makeMockRes(): Response & { _status: number; _json: Record<string, unknown> | null } {
116
+ const res = {
117
+ _status: 0,
118
+ _json: null as Record<string, unknown> | null,
119
+ status(code: number) {
120
+ res._status = code;
121
+ return res;
122
+ },
123
+ json(data: Record<string, unknown>) {
124
+ res._json = data;
125
+ return res;
126
+ },
127
+ };
128
+ return res as unknown as Response & { _status: number; _json: Record<string, unknown> | null };
129
+ }
130
+
131
+ const mockDataSources: DataSourceInfo[] = [];
132
+ const MOCK_POOL = {} as unknown as import('mssql').ConnectionPool;
133
+
134
+ const mockUserRecord = {
135
+ ID: 'user-1',
136
+ Name: 'Test User',
137
+ Email: 'test@example.com',
138
+ IsActive: true,
139
+ };
140
+
141
+ /** Configure lower-level mocks so getUserPayload succeeds for a bearer token */
142
+ function setupSuccessfulAuth() {
143
+ mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
144
+ mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
145
+ // Decode returns a valid, non-expired JWT payload
146
+ mockJwtDecode.mockReturnValue({
147
+ iss: 'https://test-issuer.com',
148
+ sub: 'user-1',
149
+ exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
150
+ });
151
+ // AuthProviderFactory finds the issuer
152
+ mockGetByIssuer.mockReturnValue({ name: 'test' });
153
+ // Validation options needed by verifyAsync
154
+ mockGetValidationOptions.mockReturnValue({ audience: ['test-audience'] });
155
+ // Signing keys callback
156
+ mockGetSigningKeys.mockReturnValue((_header: unknown, cb: (err: Error | null, key: string) => void) => {
157
+ cb(null, 'mock-key');
158
+ });
159
+ // jwt.verify calls the callback successfully
160
+ mockJwtVerify.mockImplementation(
161
+ (_token: string, _keyFn: unknown, _options: unknown, callback: (err: Error | null, decoded: unknown) => void) => {
162
+ callback(null, { iss: 'https://test-issuer.com', sub: 'user-1' });
163
+ }
164
+ );
165
+ // User info extraction
166
+ mockExtractUserInfo.mockReturnValue({
167
+ email: 'test@example.com',
168
+ firstName: 'Test',
169
+ lastName: 'User',
170
+ fullName: 'Test User',
171
+ preferredUsername: 'test@example.com',
172
+ });
173
+ // User record verification
174
+ mockVerifyUserRecord.mockResolvedValue(mockUserRecord);
175
+ }
176
+
177
+ // ─── Tests ──────────────────────────────────────────────────────────────────
178
+
179
+ describe('createUnifiedAuthMiddleware', () => {
180
+ let middleware: ReturnType<typeof createUnifiedAuthMiddleware>;
181
+
182
+ beforeEach(() => {
183
+ vi.clearAllMocks();
184
+ middleware = createUnifiedAuthMiddleware(mockDataSources);
185
+ });
186
+
187
+ describe('OPTIONS passthrough', () => {
188
+ it('should call next() without auth for OPTIONS requests', async () => {
189
+ const req = makeMockReq({ method: 'OPTIONS' });
190
+ const res = makeMockRes();
191
+ const next = vi.fn();
192
+
193
+ await middleware(req, res, next);
194
+
195
+ expect(next).toHaveBeenCalledTimes(1);
196
+ expect(mockJwtDecode).not.toHaveBeenCalled();
197
+ expect(res._status).toBe(0); // no status set
198
+ });
199
+
200
+ it('should not attach userPayload for OPTIONS requests', async () => {
201
+ const req = makeMockReq({ method: 'OPTIONS' });
202
+ const res = makeMockRes();
203
+ const next = vi.fn();
204
+
205
+ await middleware(req, res, next);
206
+
207
+ expect(req.userPayload).toBeUndefined();
208
+ });
209
+ });
210
+
211
+ describe('Successful authentication', () => {
212
+ beforeEach(() => {
213
+ setupSuccessfulAuth();
214
+ });
215
+
216
+ it('should attach userPayload to req on successful auth', async () => {
217
+ const req = makeMockReq({
218
+ headers: { authorization: 'Bearer valid-token' },
219
+ });
220
+ const res = makeMockRes();
221
+ const next = vi.fn();
222
+
223
+ await middleware(req, res, next);
224
+
225
+ expect(next).toHaveBeenCalledTimes(1);
226
+ expect(req.userPayload).toBeDefined();
227
+ expect(req.userPayload!.email).toBe('test@example.com');
228
+ expect(req.userPayload!.userRecord).toBe(mockUserRecord);
229
+ });
230
+
231
+ it('should set req.user for backward compatibility', async () => {
232
+ const req = makeMockReq({
233
+ headers: { authorization: 'Bearer valid-token' },
234
+ });
235
+ const res = makeMockRes();
236
+ const next = vi.fn();
237
+
238
+ await middleware(req, res, next);
239
+
240
+ const reqRecord = req as unknown as Record<string, unknown>;
241
+ expect(reqRecord['user']).toBeDefined();
242
+ expect((reqRecord['user'] as { email: string }).email).toBe('test@example.com');
243
+ });
244
+
245
+ it('should set req.mjUser to the userRecord', async () => {
246
+ const req = makeMockReq({
247
+ headers: { authorization: 'Bearer valid-token' },
248
+ });
249
+ const res = makeMockRes();
250
+ const next = vi.fn();
251
+
252
+ await middleware(req, res, next);
253
+
254
+ const reqRecord = req as unknown as Record<string, unknown>;
255
+ expect(reqRecord['mjUser']).toBe(mockUserRecord);
256
+ });
257
+ });
258
+
259
+ describe('Authentication failures', () => {
260
+ it('should return 401 when no authorization header is provided', async () => {
261
+ mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
262
+ mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
263
+ // No auth header → empty bearer token → empty token after strip → "Missing token"
264
+ const req = makeMockReq({ headers: {} });
265
+ const res = makeMockRes();
266
+ const next = vi.fn();
267
+
268
+ await middleware(req, res, next);
269
+
270
+ expect(next).not.toHaveBeenCalled();
271
+ expect(res._status).toBe(401);
272
+ expect(res._json).toEqual({ error: 'Authentication failed' });
273
+ });
274
+
275
+ it('should return 401 with JWT_EXPIRED code for expired tokens', async () => {
276
+ mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
277
+ mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
278
+ // jwt.decode returns a payload with expired timestamp
279
+ mockJwtDecode.mockReturnValue({
280
+ iss: 'https://test-issuer.com',
281
+ sub: 'user-1',
282
+ exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
283
+ });
284
+
285
+ const req = makeMockReq({
286
+ headers: { authorization: 'Bearer expired-token' },
287
+ });
288
+ const res = makeMockRes();
289
+ const next = vi.fn();
290
+
291
+ await middleware(req, res, next);
292
+
293
+ expect(next).not.toHaveBeenCalled();
294
+ expect(res._status).toBe(401);
295
+ expect(res._json).toEqual({
296
+ errors: [{
297
+ message: 'Token expired',
298
+ extensions: { code: 'JWT_EXPIRED' }
299
+ }]
300
+ });
301
+ });
302
+
303
+ it('should return 401 when jwt.decode returns null (corrupt token)', async () => {
304
+ mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
305
+ mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
306
+ mockJwtDecode.mockReturnValue(null);
307
+
308
+ const req = makeMockReq({
309
+ headers: { authorization: 'Bearer corrupt-token' },
310
+ });
311
+ const res = makeMockRes();
312
+ const next = vi.fn();
313
+
314
+ await middleware(req, res, next);
315
+
316
+ expect(next).not.toHaveBeenCalled();
317
+ expect(res._status).toBe(401);
318
+ expect(res._json).toEqual({ error: 'Authentication failed' });
319
+ });
320
+
321
+ it('should return 401 when verifyUserRecord returns null', async () => {
322
+ mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
323
+ mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
324
+ mockJwtDecode.mockReturnValue({
325
+ iss: 'https://test-issuer.com',
326
+ sub: 'user-1',
327
+ exp: Math.floor(Date.now() / 1000) + 3600,
328
+ });
329
+ mockGetByIssuer.mockReturnValue({ name: 'test' });
330
+ mockGetValidationOptions.mockReturnValue({ audience: ['test-audience'] });
331
+ mockGetSigningKeys.mockReturnValue(
332
+ (_h: unknown, cb: (err: Error | null, key: string) => void) => cb(null, 'key')
333
+ );
334
+ mockJwtVerify.mockImplementation(
335
+ (_t: string, _k: unknown, _o: unknown, cb: (err: Error | null, d: unknown) => void) => {
336
+ cb(null, { iss: 'https://test-issuer.com' });
337
+ }
338
+ );
339
+ mockExtractUserInfo.mockReturnValue({ email: 'unknown@example.com' });
340
+ mockVerifyUserRecord.mockResolvedValue(null); // user not found
341
+
342
+ const req = makeMockReq({
343
+ headers: { authorization: 'Bearer valid-token' },
344
+ });
345
+ const res = makeMockRes();
346
+ const next = vi.fn();
347
+
348
+ await middleware(req, res, next);
349
+
350
+ expect(next).not.toHaveBeenCalled();
351
+ expect(res._status).toBe(401);
352
+ });
353
+
354
+ it('should not call next() on auth failure', async () => {
355
+ mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
356
+ mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
357
+ mockJwtDecode.mockReturnValue(null); // invalid token
358
+
359
+ const req = makeMockReq({
360
+ headers: { authorization: 'Bearer bad-token' },
361
+ });
362
+ const res = makeMockRes();
363
+ const next = vi.fn();
364
+
365
+ await middleware(req, res, next);
366
+
367
+ expect(next).not.toHaveBeenCalled();
368
+ });
369
+ });
370
+
371
+ describe('HTTP method handling', () => {
372
+ beforeEach(() => {
373
+ setupSuccessfulAuth();
374
+ });
375
+
376
+ it('should authenticate GET requests', async () => {
377
+ const req = makeMockReq({ method: 'GET', headers: { authorization: 'Bearer tok' } });
378
+ const res = makeMockRes();
379
+ const next = vi.fn();
380
+
381
+ await middleware(req, res, next);
382
+
383
+ expect(next).toHaveBeenCalled();
384
+ });
385
+
386
+ it('should authenticate POST requests', async () => {
387
+ const req = makeMockReq({ method: 'POST', headers: { authorization: 'Bearer tok' } });
388
+ const res = makeMockRes();
389
+ const next = vi.fn();
390
+
391
+ await middleware(req, res, next);
392
+
393
+ expect(next).toHaveBeenCalled();
394
+ });
395
+
396
+ it('should authenticate PUT requests', async () => {
397
+ const req = makeMockReq({ method: 'PUT', headers: { authorization: 'Bearer tok' } });
398
+ const res = makeMockRes();
399
+ const next = vi.fn();
400
+
401
+ await middleware(req, res, next);
402
+
403
+ expect(next).toHaveBeenCalled();
404
+ });
405
+
406
+ it('should authenticate DELETE requests', async () => {
407
+ const req = makeMockReq({ method: 'DELETE', headers: { authorization: 'Bearer tok' } });
408
+ const res = makeMockRes();
409
+ const next = vi.fn();
410
+
411
+ await middleware(req, res, next);
412
+
413
+ expect(next).toHaveBeenCalled();
414
+ });
415
+ });
416
+ });
@@ -171,15 +171,16 @@ export class SkipProxyAgent extends BaseAgent {
171
171
  // Call Skip API
172
172
  const result = await this.skipSDK.chat(skipOptions);
173
173
 
174
- // Handle Skip API errors
174
+ // Handle Skip API errors — surface the actual error message, not a generic wrapper
175
175
  if (!result.success || !result.response) {
176
- LogError(`[SkipProxyAgent] Skip API call failed: ${result.error}`);
176
+ const errorMsg = result.error || 'No response received from Skip API';
177
+ LogError(`[SkipProxyAgent] Skip API call failed: ${errorMsg}`);
177
178
  return {
178
179
  finalStep: {
179
180
  terminate: true,
180
181
  step: 'Failed',
181
- message: 'Skip API call failed',
182
- errorMessage: result.error
182
+ message: errorMsg,
183
+ errorMessage: errorMsg
183
184
  } as BaseAgentNextStep<P>,
184
185
  stepCount: 1
185
186
  };
@@ -286,56 +287,108 @@ export class SkipProxyAgent extends BaseAgent {
286
287
  }
287
288
 
288
289
  /**
289
- * Map Skip API response to MJ agent next step
290
+ * Map Skip API response to MJ agent next step.
291
+ *
292
+ * Checks for Skip-level errors first, then delegates to phase-specific handlers.
290
293
  */
291
294
  private mapSkipResponseToNextStep(
292
295
  apiResponse: SkipAPIResponse,
293
296
  conversationId: string
294
297
  ): BaseAgentNextStep<ComponentSpec> {
295
- //return this.tempHack();
298
+ // Check if Skip reported an error (success: false with any responsePhase)
299
+ if (!apiResponse.success) {
300
+ return this.handleSkipError(apiResponse);
301
+ }
296
302
 
297
303
  switch (apiResponse.responsePhase) {
298
- case 'analysis_complete': {
299
- // Skip has completed analysis and returned results
300
- const completeResponse = apiResponse as SkipAPIAnalysisCompleteResponse;
301
- const componentSpec = completeResponse.componentOptions[0].option;
302
- // Filter on system message and get the last one
303
- const skipMessage = completeResponse.messages.filter(msg => msg.role === 'system').pop();
304
- return {
305
- terminate: true,
306
- step: 'Success',
307
- message: skipMessage?.content || completeResponse.title || 'Analysis complete',
308
- newPayload: componentSpec
309
- };
310
- }
304
+ case 'analysis_complete':
305
+ return this.handleAnalysisComplete(apiResponse as SkipAPIAnalysisCompleteResponse);
311
306
 
312
- case 'clarifying_question': {
313
- // Skip needs more information from the user
314
- const clarifyResponse = apiResponse as SkipAPIClarifyingQuestionResponse;
307
+ case 'clarifying_question':
308
+ return this.handleClarifyingQuestion(apiResponse as SkipAPIClarifyingQuestionResponse);
315
309
 
310
+ default: {
311
+ const msg = `Unexpected Skip response phase: ${apiResponse.responsePhase}`;
312
+ LogError(`[SkipProxyAgent] ${msg}`);
316
313
  return {
317
314
  terminate: true,
318
- step: 'Chat',
319
- message: clarifyResponse.clarifyingQuestion,
320
- responseForm: clarifyResponse.responseForm,
321
- // Pass through payload for incremental artifact building (e.g., PRD in progress)
322
- // The client will render this as an artifact and pass it back in the next request
323
- newPayload: apiResponse.payload as any
315
+ step: 'Failed',
316
+ message: msg,
317
+ errorMessage: msg,
318
+ newPayload: undefined
324
319
  };
325
320
  }
321
+ }
322
+ }
326
323
 
327
- default: {
328
- // Unknown or unexpected response phase
329
- LogError(`[SkipProxyAgent] Unknown Skip response phase: ${apiResponse.responsePhase}`);
324
+ /**
325
+ * Handle Skip error responses — extracts the most descriptive error available
326
+ */
327
+ private handleSkipError(apiResponse: SkipAPIResponse): BaseAgentNextStep<ComponentSpec> {
328
+ // Try to get a meaningful error: explicit error field, last system message, or fallback
329
+ const lastSystemMessage = apiResponse.messages
330
+ ?.filter(m => m.role === 'system')
331
+ .pop();
332
+
333
+ const errorDetail = apiResponse.error
334
+ || lastSystemMessage?.content
335
+ || 'Skip returned an error with no details';
336
+
337
+ LogError(`[SkipProxyAgent] Skip error (phase: ${apiResponse.responsePhase}): ${errorDetail}`);
338
+
339
+ return {
340
+ terminate: true,
341
+ step: 'Failed',
342
+ message: errorDetail,
343
+ errorMessage: errorDetail,
344
+ newPayload: undefined
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Handle analysis_complete phase — validates componentOptions before accessing
350
+ */
351
+ private handleAnalysisComplete(
352
+ response: SkipAPIAnalysisCompleteResponse
353
+ ): BaseAgentNextStep<ComponentSpec> {
354
+ if (!response.componentOptions || response.componentOptions.length === 0) {
355
+ const msg = 'Skip completed analysis but returned no component options. '
356
+ + `Title: "${response.title || 'none'}". `
357
+ + `Result type: "${response.resultType || 'none'}"`;
358
+ LogError(`[SkipProxyAgent] ${msg}`);
330
359
  return {
331
360
  terminate: true,
332
361
  step: 'Failed',
333
- message: `Unknown Skip response phase: ${apiResponse.responsePhase}`,
334
- errorMessage: `Unknown Skip response phase: ${apiResponse.responsePhase}`,
362
+ message: msg,
363
+ errorMessage: msg,
335
364
  newPayload: undefined
336
365
  };
337
366
  }
338
- }
367
+
368
+ const componentSpec = response.componentOptions[0].option;
369
+ const skipMessage = response.messages?.filter(msg => msg.role === 'system').pop();
370
+
371
+ return {
372
+ terminate: true,
373
+ step: 'Success',
374
+ message: skipMessage?.content || response.title || 'Analysis complete',
375
+ newPayload: componentSpec
376
+ };
377
+ }
378
+
379
+ /**
380
+ * Handle clarifying_question phase
381
+ */
382
+ private handleClarifyingQuestion(
383
+ response: SkipAPIClarifyingQuestionResponse
384
+ ): BaseAgentNextStep<ComponentSpec> {
385
+ return {
386
+ terminate: true,
387
+ step: 'Chat',
388
+ message: response.clarifyingQuestion,
389
+ responseForm: response.responseForm,
390
+ newPayload: response.payload as ComponentSpec
391
+ };
339
392
  }
340
393
 
341
394
  private tempHack(): BaseAgentNextStep<SkipAgentPayload> {
@@ -255,6 +255,19 @@ export class SkipSDK {
255
255
  if (responses && responses.length > 0) {
256
256
  const finalResponse = responses[responses.length - 1].value as SkipAPIResponse;
257
257
 
258
+ // Check if Skip itself reported an error (success: false in the response body)
259
+ if (finalResponse.success === false) {
260
+ const skipError = finalResponse.error || 'Skip API returned an error response';
261
+ LogError(`[SkipSDK] Skip API error: ${skipError}`);
262
+ return {
263
+ success: false,
264
+ response: finalResponse,
265
+ responsePhase: finalResponse.responsePhase,
266
+ error: skipError,
267
+ allResponses: responses
268
+ };
269
+ }
270
+
258
271
  return {
259
272
  success: true,
260
273
  response: finalResponse,
@@ -1,4 +1,4 @@
1
- import { ApolloServer, ApolloServerOptions } from '@apollo/server';
1
+ import { ApolloServer, ApolloServerOptions, ApolloServerPlugin } from '@apollo/server';
2
2
  import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
3
3
  import { Disposable } from 'graphql-ws';
4
4
  import { Server } from 'http';
@@ -7,27 +7,43 @@ import { AppContext } from '../types.js';
7
7
  import { Metadata } from '@memberjunction/core';
8
8
  import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
9
9
 
10
+ /**
11
+ * Creates and configures an Apollo Server instance with built-in plugins
12
+ * for HTTP drain and WebSocket cleanup.
13
+ *
14
+ * @param configOverride - Apollo Server options (typically contains the schema)
15
+ * @param servers - HTTP server and WebSocket cleanup disposable
16
+ * @param additionalPlugins - Optional additional plugins to merge with built-in plugins
17
+ */
10
18
  const buildApolloServer = (
11
19
  configOverride: ApolloServerOptions<AppContext>,
12
- { httpServer, serverCleanup }: { httpServer: Server; serverCleanup: Disposable }
13
- ) =>
14
- new ApolloServer({
20
+ { httpServer, serverCleanup }: { httpServer: Server; serverCleanup: Disposable },
21
+ additionalPlugins?: ApolloServerPlugin[]
22
+ ) => {
23
+ const builtInPlugins: ApolloServerPlugin[] = [
24
+ ApolloServerPluginDrainHttpServer({ httpServer }),
25
+ {
26
+ async serverWillStart() {
27
+ return {
28
+ async drainServer() {
29
+ await serverCleanup.dispose();
30
+ },
31
+ };
32
+ },
33
+ }
34
+ ];
35
+
36
+ const allPlugins = additionalPlugins
37
+ ? [...builtInPlugins, ...additionalPlugins]
38
+ : builtInPlugins;
39
+
40
+ return new ApolloServer({
15
41
  csrfPrevention: true,
16
42
  cache: 'bounded',
17
- plugins: [
18
- ApolloServerPluginDrainHttpServer({ httpServer }),
19
- {
20
- async serverWillStart() {
21
- return {
22
- async drainServer() {
23
- await serverCleanup.dispose();
24
- },
25
- };
26
- },
27
- }
28
- ],
43
+ plugins: allPlugins,
29
44
  introspection: enableIntrospection,
30
45
  ...configOverride,
31
46
  });
47
+ };
32
48
 
33
49
  export default buildApolloServer;