@optimizely-opal/opal-tool-ocp-sdk 1.0.0-beta.9 → 1.1.0-beta.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.
Files changed (39) hide show
  1. package/README.md +99 -200
  2. package/dist/function/GlobalToolFunction.d.ts +4 -1
  3. package/dist/function/GlobalToolFunction.d.ts.map +1 -1
  4. package/dist/function/GlobalToolFunction.js +25 -19
  5. package/dist/function/GlobalToolFunction.js.map +1 -1
  6. package/dist/function/GlobalToolFunction.test.js +114 -193
  7. package/dist/function/GlobalToolFunction.test.js.map +1 -1
  8. package/dist/function/ToolFunction.d.ts +4 -1
  9. package/dist/function/ToolFunction.d.ts.map +1 -1
  10. package/dist/function/ToolFunction.js +20 -21
  11. package/dist/function/ToolFunction.js.map +1 -1
  12. package/dist/function/ToolFunction.test.js +73 -263
  13. package/dist/function/ToolFunction.test.js.map +1 -1
  14. package/dist/logging/ToolLogger.d.ts +11 -3
  15. package/dist/logging/ToolLogger.d.ts.map +1 -1
  16. package/dist/logging/ToolLogger.js +114 -13
  17. package/dist/logging/ToolLogger.js.map +1 -1
  18. package/dist/logging/ToolLogger.test.js +177 -71
  19. package/dist/logging/ToolLogger.test.js.map +1 -1
  20. package/dist/service/Service.d.ts +10 -9
  21. package/dist/service/Service.d.ts.map +1 -1
  22. package/dist/service/Service.js +42 -74
  23. package/dist/service/Service.js.map +1 -1
  24. package/dist/service/Service.test.js +60 -95
  25. package/dist/service/Service.test.js.map +1 -1
  26. package/dist/utils/ErrorFormatter.d.ts +9 -0
  27. package/dist/utils/ErrorFormatter.d.ts.map +1 -0
  28. package/dist/utils/ErrorFormatter.js +25 -0
  29. package/dist/utils/ErrorFormatter.js.map +1 -0
  30. package/package.json +3 -3
  31. package/src/function/GlobalToolFunction.test.ts +113 -213
  32. package/src/function/GlobalToolFunction.ts +29 -29
  33. package/src/function/ToolFunction.test.ts +78 -285
  34. package/src/function/ToolFunction.ts +24 -30
  35. package/src/logging/ToolLogger.test.ts +225 -74
  36. package/src/logging/ToolLogger.ts +129 -15
  37. package/src/service/Service.test.ts +61 -113
  38. package/src/service/Service.ts +45 -79
  39. package/src/utils/ErrorFormatter.ts +31 -0
@@ -71,6 +71,11 @@ jest.mock('@zaiusinc/app-sdk', () => {
71
71
  };
72
72
  });
73
73
 
74
+ // Mock the authenticateInternalRequest function
75
+ jest.mock('../auth/AuthUtils', () => ({
76
+ authenticateInternalRequest: jest.fn().mockResolvedValue(undefined)
77
+ }));
78
+
74
79
  // Get the mocked kvStore for use in tests
75
80
  const { storage } = jest.requireMock('@zaiusinc/app-sdk');
76
81
  const mockKvStore = storage.kvStore;
@@ -250,8 +255,7 @@ describe('ToolsService', () => {
250
255
  endpoint: '/second-tool',
251
256
  http_method: 'POST',
252
257
  auth_requirements: [
253
- { provider: 'oauth2', scope_bundle: 'calendar', required: true },
254
- { provider: 'OptiID', scope_bundle: 'default', required: true }
258
+ { provider: 'oauth2', scope_bundle: 'calendar', required: true }
255
259
  ]
256
260
  });
257
261
  });
@@ -408,95 +412,55 @@ describe('ToolsService', () => {
408
412
  );
409
413
  });
410
414
 
411
- it('should return 500 error in RFC 9457 format when tool handler throws a regular error', async () => {
415
+ it('should throw error when tool handler throws a regular error', async () => {
412
416
  const errorMessage = 'Tool execution failed';
413
417
  jest.mocked(mockTool.handler).mockRejectedValueOnce(new Error(errorMessage));
414
418
 
415
419
  const mockRequest = createMockRequest();
416
- const response = await toolsService.processRequest(mockRequest, mockToolFunction);
417
420
 
418
- expect(response.status).toBe(500);
419
- expect(response.bodyJSON).toEqual({
420
- title: 'Internal Server Error',
421
- status: 500,
422
- detail: errorMessage,
423
- instance: mockTool.endpoint
424
- });
425
- expect(response.headers.get('content-type')).toBe('application/problem+json');
426
- expect(logger.error).toHaveBeenCalledWith(
427
- `Error in function ${mockTool.name}:`,
428
- expect.any(Error)
429
- );
421
+ await expect(toolsService.processRequest(mockRequest, mockToolFunction))
422
+ .rejects.toThrow(errorMessage);
430
423
  });
431
424
 
432
- it('should return 500 error with generic message when error has no message', async () => {
425
+ it('should throw when tool handler throws object without message', async () => {
433
426
  jest.mocked(mockTool.handler).mockRejectedValueOnce({});
434
427
 
435
428
  const mockRequest = createMockRequest();
436
- const response = await toolsService.processRequest(mockRequest, mockToolFunction);
437
429
 
438
- expect(response.status).toBe(500);
439
- expect(response.bodyJSON).toEqual({
440
- title: 'Internal Server Error',
441
- status: 500,
442
- detail: 'An unexpected error occurred',
443
- instance: mockTool.endpoint
444
- });
445
- expect(response.headers.get('content-type')).toBe('application/problem+json');
430
+ await expect(toolsService.processRequest(mockRequest, mockToolFunction))
431
+ .rejects.toEqual({});
446
432
  });
447
433
 
448
- it('should return custom status code when tool handler throws ToolError', async () => {
434
+ it('should throw ToolError when tool handler throws ToolError', async () => {
449
435
  const toolError = new ToolError('Resource not found', 404, 'The requested task does not exist');
450
436
  jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
451
437
 
452
438
  const mockRequest = createMockRequest();
453
- const response = await toolsService.processRequest(mockRequest, mockToolFunction);
454
439
 
455
- expect(response.status).toBe(404);
456
- expect(response.bodyJSON).toEqual({
457
- title: 'Resource not found',
458
- status: 404,
459
- detail: 'The requested task does not exist',
460
- instance: mockTool.endpoint
461
- });
462
- expect(response.headers.get('content-type')).toBe('application/problem+json');
463
- expect(logger.error).toHaveBeenCalledWith(
464
- `Error in function ${mockTool.name}:`,
465
- expect.any(ToolError)
466
- );
440
+ await expect(toolsService.processRequest(mockRequest, mockToolFunction))
441
+ .rejects.toThrow(toolError);
467
442
  });
468
443
 
469
- it('should return ToolError without detail field when detail is not provided', async () => {
444
+ it('should throw ToolError without detail when detail is not provided', async () => {
470
445
  const toolError = new ToolError('Bad request', 400);
471
446
  jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
472
447
 
473
448
  const mockRequest = createMockRequest();
474
- const response = await toolsService.processRequest(mockRequest, mockToolFunction);
475
449
 
476
- expect(response.status).toBe(400);
477
- expect(response.bodyJSON).toEqual({
478
- title: 'Bad request',
479
- status: 400,
480
- instance: mockTool.endpoint
481
- });
482
- expect(response.bodyJSON).not.toHaveProperty('detail');
483
- expect(response.headers.get('content-type')).toBe('application/problem+json');
450
+ await expect(toolsService.processRequest(mockRequest, mockToolFunction))
451
+ .rejects.toThrow(toolError);
452
+ expect(toolError.status).toBe(400);
484
453
  });
485
454
 
486
- it('should default to 500 when ToolError is created without status', async () => {
455
+ it('should throw ToolError with default 500 status when created without status', async () => {
487
456
  const toolError = new ToolError('Database error');
488
457
  jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
489
458
 
490
459
  const mockRequest = createMockRequest();
491
- const response = await toolsService.processRequest(mockRequest, mockToolFunction);
492
460
 
493
- expect(response.status).toBe(500);
494
- expect(response.bodyJSON).toEqual({
495
- title: 'Database error',
496
- status: 500,
497
- instance: mockTool.endpoint
498
- });
499
- expect(response.headers.get('content-type')).toBe('application/problem+json');
461
+ await expect(toolsService.processRequest(mockRequest, mockToolFunction))
462
+ .rejects.toThrow(toolError);
463
+ expect(toolError.status).toBe(500);
500
464
  });
501
465
  });
502
466
 
@@ -594,7 +558,7 @@ describe('ToolsService', () => {
594
558
  );
595
559
  });
596
560
 
597
- it('should return 500 error in RFC 9457 format when interaction handler throws a regular error', async () => {
561
+ it('should throw error when interaction handler throws a regular error', async () => {
598
562
  const errorMessage = 'Interaction execution failed';
599
563
  jest.mocked(mockInteraction.handler).mockRejectedValueOnce(new Error(errorMessage));
600
564
 
@@ -603,23 +567,11 @@ describe('ToolsService', () => {
603
567
  bodyJSON: { data: { param1: 'test-value' } }
604
568
  });
605
569
 
606
- const response = await toolsService.processRequest(interactionRequest, mockToolFunction);
607
-
608
- expect(response.status).toBe(500);
609
- expect(response.bodyJSON).toEqual({
610
- title: 'Internal Server Error',
611
- status: 500,
612
- detail: errorMessage,
613
- instance: mockInteraction.endpoint
614
- });
615
- expect(response.headers.get('content-type')).toBe('application/problem+json');
616
- expect(logger.error).toHaveBeenCalledWith(
617
- `Error in function ${mockInteraction.name}:`,
618
- expect.any(Error)
619
- );
570
+ await expect(toolsService.processRequest(interactionRequest, mockToolFunction))
571
+ .rejects.toThrow(errorMessage);
620
572
  });
621
573
 
622
- it('should return custom status code when interaction handler throws ToolError', async () => {
574
+ it('should throw ToolError when interaction handler throws ToolError', async () => {
623
575
  const toolError = new ToolError('Webhook validation failed', 400, 'Invalid signature');
624
576
  jest.mocked(mockInteraction.handler).mockRejectedValueOnce(toolError);
625
577
 
@@ -628,29 +580,26 @@ describe('ToolsService', () => {
628
580
  bodyJSON: { data: { param1: 'test-value' } }
629
581
  });
630
582
 
631
- const response = await toolsService.processRequest(interactionRequest, mockToolFunction);
632
-
633
- expect(response.status).toBe(400);
634
- expect(response.bodyJSON).toEqual({
635
- title: 'Webhook validation failed',
636
- status: 400,
637
- detail: 'Invalid signature',
638
- instance: mockInteraction.endpoint
639
- });
640
- expect(response.headers.get('content-type')).toBe('application/problem+json');
641
- expect(logger.error).toHaveBeenCalledWith(
642
- `Error in function ${mockInteraction.name}:`,
643
- expect.any(ToolError)
644
- );
583
+ await expect(toolsService.processRequest(interactionRequest, mockToolFunction))
584
+ .rejects.toThrow(toolError);
645
585
  });
646
586
  });
647
587
 
648
588
  describe('error cases', () => {
649
- it('should return 404 when no matching tool or interaction is found', async () => {
589
+ it('should throw ToolError with 404 when no matching tool or interaction is found', async () => {
650
590
  const unknownRequest = createMockRequest({ path: '/unknown-endpoint' });
651
- const response = await toolsService.processRequest(unknownRequest, mockToolFunction);
652
591
 
653
- expect(response.status).toBe(404);
592
+ await expect(toolsService.processRequest(unknownRequest, mockToolFunction))
593
+ .rejects.toThrow(ToolError);
594
+
595
+ try {
596
+ await toolsService.processRequest(unknownRequest, mockToolFunction);
597
+ } catch (error) {
598
+ expect(error).toBeInstanceOf(ToolError);
599
+ expect((error as ToolError).status).toBe(404);
600
+ // ToolError prepends status to message
601
+ expect((error as ToolError).message).toContain('Function not found');
602
+ }
654
603
  });
655
604
 
656
605
  it('should handle tool with OptiID auth requirements', async () => {
@@ -840,7 +789,7 @@ describe('ToolsService', () => {
840
789
  jest.clearAllMocks();
841
790
  });
842
791
 
843
- it('should validate parameters and return 400 for invalid types', async () => {
792
+ it('should throw ToolError with 400 for invalid parameter types', async () => {
844
793
  // Register a tool with specific parameter types
845
794
  const toolWithTypedParams = {
846
795
  name: 'typed_tool',
@@ -874,26 +823,25 @@ describe('ToolsService', () => {
874
823
  }
875
824
  });
876
825
 
877
- const response = await toolsService.processRequest(invalidRequest, mockToolFunction);
878
-
879
- expect(response.status).toBe(400);
880
-
881
- // Expect RFC 9457 Problem Details format
882
- expect(response.bodyJSON).toHaveProperty('title', 'One or more validation errors occurred.');
883
- expect(response.bodyJSON).toHaveProperty('status', 400);
884
- expect(response.bodyJSON).toHaveProperty('detail', 'See \'errors\' field for details.');
885
- expect(response.bodyJSON).toHaveProperty('instance', '/typed-tool');
886
- expect(response.bodyJSON).toHaveProperty('errors');
887
- expect(response.bodyJSON.errors).toHaveLength(3);
888
-
889
- // Check error structure - field and message
890
- const errors = response.bodyJSON.errors;
891
- expect(errors[0]).toHaveProperty('field', 'name');
892
- expect(errors[0]).toHaveProperty('message', "Parameter 'name' must be a string, but received number");
893
-
894
- // Check that the content type is set to application/problem+json for RFC 9457 compliance
895
- expect(response.headers).toBeDefined();
896
- expect(response.headers.get('content-type')).toBe('application/problem+json');
826
+ await expect(toolsService.processRequest(invalidRequest, mockToolFunction))
827
+ .rejects.toThrow(ToolError);
828
+
829
+ try {
830
+ await toolsService.processRequest(invalidRequest, mockToolFunction);
831
+ } catch (error) {
832
+ expect(error).toBeInstanceOf(ToolError);
833
+ const toolError = error as ToolError;
834
+ expect(toolError.status).toBe(400);
835
+ // The ToolError has title property, message includes more details
836
+ expect(toolError.toProblemDetails('/typed-tool')).toMatchObject({
837
+ title: 'One or more validation errors occurred.',
838
+ status: 400
839
+ });
840
+ expect(toolError.errors).toHaveLength(3);
841
+ expect(toolError.errors![0]).toHaveProperty('field', 'name');
842
+ expect(toolError.errors![0].message)
843
+ .toBe("Parameter 'name' must be a string, but received number");
844
+ }
897
845
 
898
846
  // Verify the handler was not called
899
847
  expect(toolWithTypedParams.handler).not.toHaveBeenCalled();
@@ -2,13 +2,13 @@
2
2
  import { AuthRequirement, IslandResponse, Parameter } from '../types/Models';
3
3
  import { ToolError } from '../types/ToolError';
4
4
  import * as App from '@zaiusinc/app-sdk';
5
- import { logger, Headers, getAppContext } from '@zaiusinc/app-sdk';
5
+ import { logger, getAppContext } from '@zaiusinc/app-sdk';
6
6
  import { ToolFunction } from '../function/ToolFunction';
7
7
  import { GlobalToolFunction } from '../function/GlobalToolFunction';
8
8
  import { ParameterValidator } from '../validation/ParameterValidator';
9
9
 
10
10
  /**
11
- * Default OptiID authentication requirement that will be enforced for all tools
11
+ * Default OptiID authentication requirement added when tool doesn't specify any auth
12
12
  */
13
13
  const DEFAULT_OPTIID_AUTH = new AuthRequirement('OptiID', 'default', true);
14
14
 
@@ -219,50 +219,35 @@ export class ToolsService {
219
219
  }
220
220
 
221
221
  /**
222
- * Enforce OptiID authentication for tools by ensuring OptiID auth requirement is present
222
+ * Apply default auth requirements if none are specified
223
223
  * @param authRequirements Original authentication requirements
224
- * @returns Enforced authentication requirements with OptiID
224
+ * @returns Auth requirements with default OptiID if none specified, otherwise unchanged
225
225
  */
226
- private enforceOptiIdAuth(authRequirements?: AuthRequirement[]): AuthRequirement[] {
227
- const hasOptiIdProvider = authRequirements
228
- && authRequirements.some((auth) => auth.provider.toLowerCase() === 'optiid');
229
-
230
- if (hasOptiIdProvider) {
231
- return authRequirements;
226
+ private withDefaultAuthRequirements(authRequirements?: AuthRequirement[]): AuthRequirement[] {
227
+ // Only add default OptiID if no auth requirements are specified
228
+ if (!authRequirements || authRequirements.length === 0) {
229
+ return [DEFAULT_OPTIID_AUTH];
232
230
  }
233
231
 
234
- return [...(authRequirements || []), DEFAULT_OPTIID_AUTH];
232
+ // Respect developer's choice - return as-is
233
+ return authRequirements;
235
234
  }
236
235
 
237
236
  /**
238
- * Format an error as RFC 9457 Problem Details response
239
- * @param error The error to format
240
- * @param instance URI reference identifying the specific occurrence
241
- * @returns RFC 9457 compliant Response
237
+ * Check if an endpoint requires OptiID authentication
238
+ * Tools: Check auth requirements for OptiID provider
239
+ * Interactions: Always require OptiID
240
+ * @param endpoint The endpoint path to check
241
+ * @returns true if the endpoint requires OptiID auth
242
242
  */
243
- private formatErrorResponse(error: any, instance: string): App.Response {
244
- let status = 500;
245
- let problemDetails: Record<string, unknown>;
246
-
247
- if (error instanceof ToolError) {
248
- // Use ToolError's status and format
249
- status = error.status;
250
- problemDetails = error.toProblemDetails(instance);
251
- } else {
252
- // Convert regular errors to RFC 9457 format with 500 status
253
- problemDetails = {
254
- title: 'Internal Server Error',
255
- status: 500,
256
- detail: error.message || 'An unexpected error occurred',
257
- instance
258
- };
243
+ public requiresOptiIdAuth(endpoint: string): boolean {
244
+ const func = this.functions.get(endpoint);
245
+ if (func) {
246
+ return func.authRequirements.some((auth) => auth.provider.toLowerCase() === 'optiid');
259
247
  }
260
-
261
- return new App.Response(
262
- status,
263
- problemDetails,
264
- new Headers([['content-type', 'application/problem+json']])
265
- );
248
+ // Interactions always require OptiID
249
+ const interaction = this.interactions.get(endpoint);
250
+ return !!interaction;
266
251
  }
267
252
 
268
253
  /**
@@ -272,7 +257,7 @@ export class ToolsService {
272
257
  * @param handler Function implementing the tool
273
258
  * @param parameters List of parameters for the tool
274
259
  * @param endpoint API endpoint for the tool
275
- * @param authRequirements Authentication requirements (optional)
260
+ * @param authRequirements Authentication requirements (optional - defaults to OptiID if not specified)
276
261
  */
277
262
  public registerTool<TAuthData>(
278
263
  name: string,
@@ -286,15 +271,14 @@ export class ToolsService {
286
271
  endpoint: string,
287
272
  authRequirements?: AuthRequirement[]
288
273
  ): void {
289
- // Enforce OptiID authentication for all tools
290
- const enforcedAuthRequirements = this.enforceOptiIdAuth(authRequirements);
274
+ const resolvedAuthRequirements = this.withDefaultAuthRequirements(authRequirements);
291
275
  const func = new Tool<TAuthData>(
292
276
  name,
293
277
  description,
294
278
  parameters,
295
279
  endpoint,
296
280
  handler,
297
- enforcedAuthRequirements
281
+ resolvedAuthRequirements
298
282
  );
299
283
  this.functions.set(endpoint, func);
300
284
  }
@@ -327,61 +311,43 @@ export class ToolsService {
327
311
  return await this.handleDiscoveryRequest(functionContext);
328
312
  }
329
313
 
330
- // Handle overrides endpoint
314
+ // Handle overrides endpoint (auth handled by function layer)
331
315
  if (req.path === '/overrides') {
332
316
  return await this.handleOverridesRequest(req, functionContext);
333
317
  }
334
318
 
335
319
  // Handle regular tool functions
320
+ // Auth is already validated by the function layer
336
321
  const func = this.functions.get(req.path);
337
322
  if (func) {
338
- try {
339
- let params;
340
- if (req.bodyJSON && req.bodyJSON.parameters) {
341
- params = req.bodyJSON.parameters;
342
- } else {
343
- params = req.bodyJSON;
344
- }
345
-
346
- // Validate parameters before calling the handler (only if tool has parameter definitions)
347
- // ParameterValidator.validate() throws ToolError if validation fails
348
- if (func.parameters && func.parameters.length > 0) {
349
- ParameterValidator.validate(params, func.parameters, func.endpoint);
350
- }
323
+ const params = req.bodyJSON?.parameters ?? req.bodyJSON;
351
324
 
352
- // Extract auth data from body JSON
353
- const authData = req.bodyJSON ? req.bodyJSON.auth : undefined;
354
- const result = await func.handler(functionContext, params, authData);
355
- return new App.Response(200, result);
356
- } catch (error: any) {
357
- logger.error(`Error in function ${func.name}:`, error);
358
- return this.formatErrorResponse(error, func.endpoint);
325
+ // Validate parameters before calling the handler (only if tool has parameter definitions)
326
+ // ParameterValidator.validate() throws ToolError if validation fails
327
+ if (func.parameters && func.parameters.length > 0) {
328
+ ParameterValidator.validate(params, func.parameters, func.endpoint);
359
329
  }
330
+
331
+ // Extract auth data from body JSON
332
+ const authData = req.bodyJSON?.auth;
333
+ const result = await func.handler(functionContext, params, authData);
334
+ return new App.Response(200, result);
360
335
  }
361
336
 
362
337
  // Handle interactions
338
+ // Auth is already validated by the function layer
363
339
  const interaction = this.interactions.get(req.path);
364
340
  if (interaction) {
365
- try {
366
- let params;
367
- if (req.bodyJSON && req.bodyJSON.data) {
368
- params = req.bodyJSON.data;
369
- } else {
370
- params = req.bodyJSON;
371
- }
341
+ const params = req.bodyJSON?.data ?? req.bodyJSON;
372
342
 
373
- // Extract auth data from body JSON
374
- const authData = req.bodyJSON ? req.bodyJSON.auth : undefined;
343
+ // Extract auth data from body JSON
344
+ const authData = req.bodyJSON?.auth;
375
345
 
376
- const result = await interaction.handler(functionContext, params, authData);
377
- return new App.Response(200, result);
378
- } catch (error: any) {
379
- logger.error(`Error in function ${interaction.name}:`, error);
380
- return this.formatErrorResponse(error, interaction.endpoint);
381
-
382
- }
346
+ const result = await interaction.handler(functionContext, params, authData);
347
+ return new App.Response(200, result);
383
348
  }
384
- return new App.Response(404, 'Function not found');
349
+
350
+ throw new ToolError('Function not found', 404);
385
351
  }
386
352
 
387
353
  /**
@@ -0,0 +1,31 @@
1
+ import { Response, Headers } from '@zaiusinc/app-sdk';
2
+ import { ToolError } from '../types/ToolError';
3
+
4
+ /**
5
+ * Format an error as RFC 9457 Problem Details response
6
+ * @param error The error to format (ToolError or generic Error)
7
+ * @param instance URI reference identifying the specific occurrence (typically request path)
8
+ * @returns RFC 9457 compliant Response
9
+ */
10
+ export function formatErrorResponse(error: unknown, instance: string): Response {
11
+ if (error instanceof ToolError) {
12
+ return new Response(
13
+ error.status,
14
+ error.toProblemDetails(instance),
15
+ new Headers([['content-type', 'application/problem+json']])
16
+ );
17
+ }
18
+
19
+ // Fallback for generic errors
20
+ const message = error instanceof Error ? error.message : 'An unexpected error occurred';
21
+ return new Response(
22
+ 500,
23
+ {
24
+ title: 'Internal Server Error',
25
+ status: 500,
26
+ detail: message,
27
+ instance
28
+ },
29
+ new Headers([['content-type', 'application/problem+json']])
30
+ );
31
+ }