@optimizely-opal/opal-tool-ocp-sdk 1.0.0 → 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.
@@ -2,11 +2,14 @@ import { GlobalToolFunction } from './GlobalToolFunction';
2
2
  import { toolsService } from '../service/Service';
3
3
  import { Response, getAppContext } from '@zaiusinc/app-sdk';
4
4
  import { getTokenVerifier } from '../auth/TokenVerifier';
5
+ import { ToolError } from '../types/ToolError';
6
+ import { authenticateGlobalRequest } from '../auth/AuthUtils';
5
7
 
6
8
  // Mock the dependencies
7
9
  jest.mock('../service/Service', () => ({
8
10
  toolsService: {
9
11
  processRequest: jest.fn(),
12
+ requiresOptiIdAuth: jest.fn(),
10
13
  },
11
14
  }));
12
15
 
@@ -14,6 +17,12 @@ jest.mock('../auth/TokenVerifier', () => ({
14
17
  getTokenVerifier: jest.fn(),
15
18
  }));
16
19
 
20
+ jest.mock('../auth/AuthUtils', () => ({
21
+ authenticateGlobalRequest: jest.fn().mockResolvedValue(undefined),
22
+ authenticateInternalRequest: jest.fn().mockResolvedValue(undefined),
23
+ extractAuthData: jest.fn().mockReturnValue(null),
24
+ }));
25
+
17
26
  jest.mock('@zaiusinc/app-sdk', () => ({
18
27
  GlobalFunction: class {
19
28
  protected request: any;
@@ -180,6 +189,7 @@ describe('GlobalToolFunction', () => {
180
189
  // Assert
181
190
  expect(result).toBe(mockResponse);
182
191
  expect(mockGetTokenVerifier).not.toHaveBeenCalled(); // Should not verify token for discovery
192
+ // processRequest is called without authValidator (auth handled internally)
183
193
  expect(mockProcessRequest).toHaveBeenCalledWith(discoveryRequest, globalToolFunction);
184
194
  });
185
195
  });
@@ -292,278 +302,168 @@ describe('GlobalToolFunction', () => {
292
302
  });
293
303
 
294
304
  describe('perform', () => {
295
- it('should execute successfully with valid token (no organization validation)', async () => {
296
- // Setup mock token verifier to return true for valid token
297
- mockTokenVerifier.verify.mockResolvedValue(true);
305
+ it('should call processRequest and return its result', async () => {
306
+ // Setup mock to return a response
298
307
  mockProcessRequest.mockResolvedValue(mockResponse);
299
308
 
300
309
  const result = await globalToolFunction.perform();
301
310
 
302
311
  expect(result).toBe(mockResponse);
303
- expect(mockGetTokenVerifier).toHaveBeenCalled();
304
- expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
305
- // Note: getAppContext should NOT be called for global functions
306
- expect(mockGetAppContext).not.toHaveBeenCalled();
312
+ // processRequest is called with request and context (no authValidator)
307
313
  expect(mockProcessRequest).toHaveBeenCalledWith(mockRequest, globalToolFunction);
308
314
  });
315
+ });
309
316
 
310
- it('should execute successfully even with different organization ID', async () => {
311
- // Update mock request with different customer_id (should still work for global functions)
312
- const requestWithDifferentOrgId = {
313
- ...mockRequest,
314
- bodyJSON: {
315
- ...mockRequest.bodyJSON,
316
- auth: {
317
- ...mockRequest.bodyJSON.auth,
318
- credentials: {
319
- ...mockRequest.bodyJSON.auth.credentials,
320
- customer_id: 'completely-different-org-456'
321
- }
322
- }
317
+ describe('inheritance', () => {
318
+ it('should be an instance of GlobalFunction', () => {
319
+ // Assert
320
+ expect(globalToolFunction).toBeInstanceOf(GlobalToolFunction);
321
+ });
322
+
323
+ it('should have access to the request property', () => {
324
+ // Assert
325
+ expect(globalToolFunction.getRequest()).toBe(mockRequest);
326
+ });
327
+ });
328
+
329
+ describe('system paths routing', () => {
330
+ beforeEach(() => {
331
+ jest.clearAllMocks();
332
+ });
333
+
334
+ it('should forward /overrides to processRequest directly', async () => {
335
+ const overridesRequest = {
336
+ path: '/overrides',
337
+ method: 'DELETE',
338
+ bodyJSON: {},
339
+ headers: {
340
+ get: jest.fn().mockImplementation((name: string) => {
341
+ if (name === 'x-opal-thread-id') return 'test-thread-id';
342
+ return null;
343
+ })
323
344
  }
324
345
  };
325
346
 
326
- const globalToolFunctionWithDifferentOrgId = new TestGlobalToolFunction(requestWithDifferentOrgId);
327
- mockTokenVerifier.verify.mockResolvedValue(true);
328
347
  mockProcessRequest.mockResolvedValue(mockResponse);
329
-
330
- const result = await globalToolFunctionWithDifferentOrgId.perform();
348
+ const globalFunc = new TestGlobalToolFunction(overridesRequest);
349
+ const result = await globalFunc.perform();
331
350
 
332
351
  expect(result).toBe(mockResponse);
333
- expect(mockGetTokenVerifier).toHaveBeenCalled();
334
- expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
335
- // Note: Should NOT validate organization ID for global functions
336
- expect(mockGetAppContext).not.toHaveBeenCalled();
337
- expect(mockProcessRequest).toHaveBeenCalledWith(requestWithDifferentOrgId, globalToolFunctionWithDifferentOrgId);
352
+ // /overrides is forwarded directly to processRequest (auth handled there)
353
+ expect(mockProcessRequest).toHaveBeenCalledWith(overridesRequest, globalFunc);
338
354
  });
339
355
 
340
- it('should execute successfully even without customer_id', async () => {
341
- // Create request without customer_id (should still work for global functions)
342
- const requestWithoutCustomerId = {
356
+ it('should forward tool endpoints to processRequest', async () => {
357
+ const toolRequest = {
343
358
  ...mockRequest,
344
- bodyJSON: {
345
- ...mockRequest.bodyJSON,
346
- auth: {
347
- ...mockRequest.bodyJSON.auth,
348
- credentials: {
349
- ...mockRequest.bodyJSON.auth.credentials,
350
- customer_id: undefined
351
- }
352
- }
359
+ path: '/some-tool',
360
+ headers: {
361
+ get: jest.fn().mockImplementation((name: string) => {
362
+ if (name === 'x-opal-thread-id') return 'test-thread-id';
363
+ return null;
364
+ })
353
365
  }
354
366
  };
355
367
 
356
- const globalToolFunctionWithoutCustomerId = new TestGlobalToolFunction(requestWithoutCustomerId);
357
- mockTokenVerifier.verify.mockResolvedValue(true);
368
+ // Tool does not require OptiID auth
369
+ (toolsService.requiresOptiIdAuth as jest.Mock).mockReturnValue(false);
358
370
  mockProcessRequest.mockResolvedValue(mockResponse);
359
371
 
360
- const result = await globalToolFunctionWithoutCustomerId.perform();
372
+ const globalFunc = new TestGlobalToolFunction(toolRequest);
373
+ const result = await globalFunc.perform();
361
374
 
362
375
  expect(result).toBe(mockResponse);
363
- expect(mockGetTokenVerifier).toHaveBeenCalled();
364
- expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
365
- expect(mockGetAppContext).not.toHaveBeenCalled();
366
- expect(mockProcessRequest).toHaveBeenCalledWith(requestWithoutCustomerId, globalToolFunctionWithoutCustomerId);
376
+ expect(mockProcessRequest).toHaveBeenCalledWith(toolRequest, globalFunc);
367
377
  });
378
+ });
368
379
 
369
- it('should return 403 response with invalid token', async () => {
370
- // Setup mock token verifier to return false
371
- mockTokenVerifier.verify.mockResolvedValue(false);
372
-
373
- const result = await globalToolFunction.perform();
374
-
375
- expect(result.status).toBe(403);
376
- expect(result.bodyJSON).toEqual({
377
- title: 'Forbidden',
378
- status: 403,
379
- detail: 'Invalid OptiID access token',
380
- instance: '/test'
381
- });
382
- expect(mockGetTokenVerifier).toHaveBeenCalled();
383
- expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
384
- expect(mockProcessRequest).not.toHaveBeenCalled();
380
+ describe('error formatting', () => {
381
+ beforeEach(() => {
382
+ jest.clearAllMocks();
385
383
  });
386
384
 
387
- it('should return 403 response when access token is missing', async () => {
388
- // Create request without access token
389
- const requestWithoutToken = {
385
+ it('should format ToolError as RFC 9457 response when processRequest throws ToolError', async () => {
386
+ const toolRequest = {
390
387
  ...mockRequest,
391
- bodyJSON: {
392
- ...mockRequest.bodyJSON,
393
- auth: {
394
- ...mockRequest.bodyJSON.auth,
395
- credentials: {
396
- ...mockRequest.bodyJSON.auth.credentials,
397
- access_token: undefined
398
- }
399
- }
388
+ path: '/some-tool',
389
+ headers: {
390
+ get: jest.fn().mockImplementation((name: string) => {
391
+ if (name === 'x-opal-thread-id') return 'test-thread-id';
392
+ return null;
393
+ })
400
394
  }
401
395
  };
402
396
 
403
- const globalToolFunctionWithoutToken = new TestGlobalToolFunction(requestWithoutToken);
397
+ const toolError = new ToolError('Resource not found', 404, 'The requested resource does not exist');
398
+ mockProcessRequest.mockRejectedValue(toolError);
399
+ (toolsService.requiresOptiIdAuth as jest.Mock).mockReturnValue(false);
404
400
 
405
- const result = await globalToolFunctionWithoutToken.perform();
401
+ const globalFunc = new TestGlobalToolFunction(toolRequest);
402
+ const result = await globalFunc.perform();
406
403
 
407
- expect(result.status).toBe(403);
404
+ expect(result.status).toBe(404);
408
405
  expect(result.bodyJSON).toEqual({
409
- title: 'Forbidden',
410
- status: 403,
411
- detail: 'OptiID access token is required',
412
- instance: '/test'
406
+ title: 'Resource not found',
407
+ status: 404,
408
+ detail: 'The requested resource does not exist',
409
+ instance: '/some-tool'
413
410
  });
414
- expect(mockGetTokenVerifier).not.toHaveBeenCalled();
415
- expect(mockProcessRequest).not.toHaveBeenCalled();
416
411
  });
417
412
 
418
- it('should return 403 response when provider is not OptiID', async () => {
419
- // Create request with different provider
420
- const requestWithDifferentProvider = {
413
+ it('should format generic Error as 500 RFC 9457 response when processRequest throws', async () => {
414
+ const toolRequest = {
421
415
  ...mockRequest,
422
- bodyJSON: {
423
- ...mockRequest.bodyJSON,
424
- auth: {
425
- ...mockRequest.bodyJSON.auth,
426
- provider: 'SomeOtherProvider'
427
- }
416
+ path: '/some-tool',
417
+ headers: {
418
+ get: jest.fn().mockImplementation((name: string) => {
419
+ if (name === 'x-opal-thread-id') return 'test-thread-id';
420
+ return null;
421
+ })
428
422
  }
429
423
  };
430
424
 
431
- const globalToolFunctionWithDifferentProvider = new TestGlobalToolFunction(requestWithDifferentProvider);
425
+ mockProcessRequest.mockRejectedValue(new Error('Database connection failed'));
426
+ (toolsService.requiresOptiIdAuth as jest.Mock).mockReturnValue(false);
432
427
 
433
- const result = await globalToolFunctionWithDifferentProvider.perform();
428
+ const globalFunc = new TestGlobalToolFunction(toolRequest);
429
+ const result = await globalFunc.perform();
434
430
 
435
- expect(result.status).toBe(403);
431
+ expect(result.status).toBe(500);
436
432
  expect(result.bodyJSON).toEqual({
437
- title: 'Forbidden',
438
- status: 403,
439
- detail: 'Only OptiID authentication provider is supported',
440
- instance: '/test'
433
+ title: 'Internal Server Error',
434
+ status: 500,
435
+ detail: 'Database connection failed',
436
+ instance: '/some-tool'
441
437
  });
442
- expect(mockGetTokenVerifier).not.toHaveBeenCalled();
443
- expect(mockProcessRequest).not.toHaveBeenCalled();
444
438
  });
445
439
 
446
- it('should return 403 response when auth structure is missing', async () => {
447
- // Create request without auth structure
448
- const requestWithoutAuth = {
440
+ it('should format ToolError from authorization failure as RFC 9457 response', async () => {
441
+ const toolRequest = {
449
442
  ...mockRequest,
450
- bodyJSON: {
451
- parameters: mockRequest.bodyJSON.parameters,
452
- environment: mockRequest.bodyJSON.environment
443
+ path: '/some-tool',
444
+ headers: {
445
+ get: jest.fn().mockImplementation((name: string) => {
446
+ if (name === 'x-opal-thread-id') return 'test-thread-id';
447
+ return null;
448
+ })
453
449
  }
454
450
  };
455
451
 
456
- const globalToolFunctionWithoutAuth = new TestGlobalToolFunction(requestWithoutAuth);
457
-
458
- const result = await globalToolFunctionWithoutAuth.perform();
459
-
460
- expect(result.status).toBe(403);
461
- expect(result.bodyJSON).toEqual({
462
- title: 'Forbidden',
463
- status: 403,
464
- detail: 'Authentication data is required',
465
- instance: '/test'
466
- });
467
- expect(mockGetTokenVerifier).not.toHaveBeenCalled();
468
- expect(mockProcessRequest).not.toHaveBeenCalled();
469
- });
470
-
471
- it('should return 403 response when token verifier initialization fails', async () => {
472
- // Setup mock to fail during token verifier initialization
473
- mockGetTokenVerifier.mockRejectedValue(new Error('Failed to initialize token verifier'));
474
-
475
- const result = await globalToolFunction.perform();
476
-
477
- expect(result.status).toBe(403);
478
- expect(result.bodyJSON).toEqual({
479
- title: 'Forbidden',
480
- status: 403,
481
- detail: 'Token verification failed',
482
- instance: '/test'
483
- });
484
- expect(mockGetTokenVerifier).toHaveBeenCalled();
485
- expect(mockProcessRequest).not.toHaveBeenCalled();
486
- });
487
-
488
- it('should return 403 response when token validation throws an error', async () => {
489
- // Setup mock token verifier to throw an error
490
- mockTokenVerifier.verify.mockRejectedValue(new Error('Token validation failed'));
452
+ const authError = new ToolError('Unauthorized', 403, 'Invalid access token');
453
+ (toolsService.requiresOptiIdAuth as jest.Mock).mockReturnValue(true);
454
+ jest.mocked(authenticateGlobalRequest).mockRejectedValue(authError);
491
455
 
492
- const result = await globalToolFunction.perform();
456
+ const globalFunc = new TestGlobalToolFunction(toolRequest);
457
+ const result = await globalFunc.perform();
493
458
 
494
459
  expect(result.status).toBe(403);
495
460
  expect(result.bodyJSON).toEqual({
496
- title: 'Forbidden',
461
+ title: 'Unauthorized',
497
462
  status: 403,
498
- detail: 'Token verification failed',
499
- instance: '/test'
463
+ detail: 'Invalid access token',
464
+ instance: '/some-tool'
500
465
  });
501
- expect(mockGetTokenVerifier).toHaveBeenCalled();
502
- expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
503
466
  expect(mockProcessRequest).not.toHaveBeenCalled();
504
467
  });
505
468
  });
506
-
507
- describe('inheritance', () => {
508
- it('should be an instance of GlobalFunction', () => {
509
- // Assert
510
- expect(globalToolFunction).toBeInstanceOf(GlobalToolFunction);
511
- });
512
-
513
- it('should have access to the request property', () => {
514
- // Assert
515
- expect(globalToolFunction.getRequest()).toBe(mockRequest);
516
- });
517
- });
518
-
519
- describe('authentication differences from ToolFunction', () => {
520
- it('should NOT validate organization ID (unlike ToolFunction)', async () => {
521
- // This test demonstrates the key difference between GlobalToolFunction and ToolFunction
522
- // GlobalToolFunction should work with any customer_id, while ToolFunction requires matching org ID
523
-
524
- const requestWithRandomOrgId = {
525
- ...mockRequest,
526
- bodyJSON: {
527
- ...mockRequest.bodyJSON,
528
- auth: {
529
- ...mockRequest.bodyJSON.auth,
530
- credentials: {
531
- ...mockRequest.bodyJSON.auth.credentials,
532
- customer_id: 'random-org-999' // This would fail in ToolFunction but should work here
533
- }
534
- }
535
- }
536
- };
537
-
538
- const globalToolFunctionWithRandomOrgId = new TestGlobalToolFunction(requestWithRandomOrgId);
539
- mockTokenVerifier.verify.mockResolvedValue(true);
540
- mockProcessRequest.mockResolvedValue(mockResponse);
541
-
542
- const result = await globalToolFunctionWithRandomOrgId.perform();
543
-
544
- // Should succeed even with different org ID
545
- expect(result).toBe(mockResponse);
546
- expect(mockGetTokenVerifier).toHaveBeenCalled();
547
- expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
548
- // Crucially: getAppContext should NOT be called (no org validation)
549
- expect(mockGetAppContext).not.toHaveBeenCalled();
550
- expect(mockProcessRequest).toHaveBeenCalledWith(requestWithRandomOrgId, globalToolFunctionWithRandomOrgId);
551
- });
552
-
553
- it('should only require valid OptiID token (no organization constraints)', async () => {
554
- // Test that only token validation is performed, no org validation
555
- mockTokenVerifier.verify.mockResolvedValue(true);
556
- mockProcessRequest.mockResolvedValue(mockResponse);
557
-
558
- const result = await globalToolFunction.perform();
559
-
560
- expect(result).toBe(mockResponse);
561
- expect(mockGetTokenVerifier).toHaveBeenCalled();
562
- expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
563
-
564
- // Key assertion: getAppContext should never be called for global functions
565
- expect(mockGetAppContext).not.toHaveBeenCalled();
566
- expect(mockProcessRequest).toHaveBeenCalledWith(mockRequest, globalToolFunction);
567
- });
568
- });
569
469
  });
@@ -1,9 +1,9 @@
1
- import { GlobalFunction, Response, Headers, amendLogContext } from '@zaiusinc/app-sdk';
2
- import { authenticateGlobalRequest, extractAuthData } from '../auth/AuthUtils';
1
+ import { GlobalFunction, Response, amendLogContext } from '@zaiusinc/app-sdk';
2
+ import { authenticateGlobalRequest, authenticateInternalRequest, extractAuthData } from '../auth/AuthUtils';
3
3
  import { toolsService } from '../service/Service';
4
4
  import { ToolLogger } from '../logging/ToolLogger';
5
- import { ToolError } from '../types/ToolError';
6
5
  import { ReadyResponse } from '../types/Models';
6
+ import { formatErrorResponse } from '../utils/ErrorFormatter';
7
7
 
8
8
  /**
9
9
  * Abstract base class for global tool-based function execution
@@ -46,29 +46,6 @@ export abstract class GlobalToolFunction extends GlobalFunction {
46
46
  }
47
47
 
48
48
  private async handleRequest(): Promise<Response> {
49
- try {
50
- await this.authorizeRequest();
51
- } catch (error) {
52
- if (error instanceof ToolError) {
53
- return new Response(
54
- error.status,
55
- error.toProblemDetails(this.request.path),
56
- new Headers([['content-type', 'application/problem+json']])
57
- );
58
- }
59
- // Fallback for unexpected errors
60
- return new Response(
61
- 500,
62
- {
63
- title: 'Internal Server Error',
64
- status: 500,
65
- detail: 'An unexpected error occurred during authentication',
66
- instance: this.request.path
67
- },
68
- new Headers([['content-type', 'application/problem+json']])
69
- );
70
- }
71
-
72
49
  if (this.request.path === '/ready') {
73
50
  const readyResult = await this.ready();
74
51
  const readyResponse = typeof readyResult === 'boolean'
@@ -77,15 +54,38 @@ export abstract class GlobalToolFunction extends GlobalFunction {
77
54
  return new Response(200, readyResponse);
78
55
  }
79
56
 
80
- return toolsService.processRequest(this.request, this);
57
+ try {
58
+ await this.authorizeRequest();
59
+
60
+ return await toolsService.processRequest(this.request, this);
61
+ } catch (error) {
62
+ return formatErrorResponse(error, this.request.path);
63
+ }
81
64
  }
82
65
 
83
66
  /**
84
- * Authenticate the incoming request by validating only the OptiID token
67
+ * Authenticate the incoming request based on the endpoint
68
+ * - /discovery: No auth required
69
+ * - /overrides: Internal auth (header-based token)
70
+ * - Tools/interactions: Global auth if OptiID is required
85
71
  *
86
72
  * @throws {ToolError} If authentication fails
87
73
  */
88
74
  private async authorizeRequest(): Promise<void> {
89
- await authenticateGlobalRequest(this.request);
75
+ // Discovery endpoint doesn't require auth
76
+ if (this.request.path === '/discovery') {
77
+ return;
78
+ }
79
+
80
+ // Use internal authentication for overrides endpoint (header-based token)
81
+ if (this.request.path === '/overrides') {
82
+ await authenticateInternalRequest(this.request);
83
+ return;
84
+ }
85
+
86
+ // Tool/interaction endpoints - authenticate only if OptiID is required
87
+ if (toolsService.requiresOptiIdAuth(this.request.path)) {
88
+ await authenticateGlobalRequest(this.request);
89
+ }
90
90
  }
91
91
  }