@optimizely-opal/opal-tool-ocp-sdk 0.0.0-devmg.13 → 1.0.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 (43) hide show
  1. package/README.md +108 -15
  2. package/dist/auth/AuthUtils.d.ts +26 -0
  3. package/dist/auth/AuthUtils.d.ts.map +1 -0
  4. package/dist/auth/AuthUtils.js +109 -0
  5. package/dist/auth/AuthUtils.js.map +1 -0
  6. package/dist/auth/AuthUtils.test.d.ts +2 -0
  7. package/dist/auth/AuthUtils.test.d.ts.map +1 -0
  8. package/dist/auth/AuthUtils.test.js +601 -0
  9. package/dist/auth/AuthUtils.test.js.map +1 -0
  10. package/dist/auth/TokenVerifier.d.ts.map +1 -1
  11. package/dist/auth/TokenVerifier.js +0 -1
  12. package/dist/auth/TokenVerifier.js.map +1 -1
  13. package/dist/auth/TokenVerifier.test.js +9 -0
  14. package/dist/auth/TokenVerifier.test.js.map +1 -1
  15. package/dist/function/GlobalToolFunction.d.ts +27 -0
  16. package/dist/function/GlobalToolFunction.d.ts.map +1 -0
  17. package/dist/function/GlobalToolFunction.js +53 -0
  18. package/dist/function/GlobalToolFunction.js.map +1 -0
  19. package/dist/function/GlobalToolFunction.test.d.ts +2 -0
  20. package/dist/function/GlobalToolFunction.test.d.ts.map +1 -0
  21. package/dist/function/GlobalToolFunction.test.js +425 -0
  22. package/dist/function/GlobalToolFunction.test.js.map +1 -0
  23. package/dist/function/ToolFunction.d.ts +1 -2
  24. package/dist/function/ToolFunction.d.ts.map +1 -1
  25. package/dist/function/ToolFunction.js +3 -35
  26. package/dist/function/ToolFunction.js.map +1 -1
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/service/Service.d.ts +8 -7
  32. package/dist/service/Service.d.ts.map +1 -1
  33. package/dist/service/Service.js.map +1 -1
  34. package/package.json +3 -4
  35. package/src/auth/AuthUtils.test.ts +729 -0
  36. package/src/auth/AuthUtils.ts +117 -0
  37. package/src/auth/TokenVerifier.test.ts +11 -0
  38. package/src/auth/TokenVerifier.ts +0 -1
  39. package/src/function/GlobalToolFunction.test.ts +505 -0
  40. package/src/function/GlobalToolFunction.ts +56 -0
  41. package/src/function/ToolFunction.ts +4 -41
  42. package/src/index.ts +1 -0
  43. package/src/service/Service.ts +33 -9
@@ -0,0 +1,117 @@
1
+ import { getAppContext, logger } from '@zaiusinc/app-sdk';
2
+ import { getTokenVerifier } from './TokenVerifier';
3
+ import { OptiIdAuthData } from '../types/Models';
4
+
5
+ /**
6
+ * Validate the OptiID access token
7
+ *
8
+ * @param accessToken - The access token to validate
9
+ * @returns true if the token is valid
10
+ */
11
+ async function validateAccessToken(accessToken: string | undefined): Promise<boolean> {
12
+ try {
13
+ if (!accessToken) {
14
+ return false;
15
+ }
16
+ const tokenVerifier = await getTokenVerifier();
17
+ return await tokenVerifier.verify(accessToken);
18
+ } catch (error) {
19
+ logger.error('OptiID token validation failed:', error);
20
+ return false;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Extract and validate basic OptiID authentication data from request
26
+ *
27
+ * @param request - The incoming request
28
+ * @returns object with authData and accessToken, or null if invalid
29
+ */
30
+ export function extractAuthData(request: any): { authData: OptiIdAuthData; accessToken: string } | null {
31
+ const authData = request?.bodyJSON?.auth as OptiIdAuthData;
32
+ const accessToken = authData?.credentials?.access_token;
33
+ if (!accessToken || authData?.provider?.toLowerCase() !== 'optiid') {
34
+ return null;
35
+ }
36
+
37
+ return { authData, accessToken };
38
+ }
39
+
40
+ /**
41
+ * Validate organization ID matches the app context
42
+ *
43
+ * @param customerId - The customer ID from the auth data
44
+ * @returns true if the organization ID is valid
45
+ */
46
+ function validateOrganizationId(customerId: string | undefined): boolean {
47
+ if (!customerId) {
48
+ logger.error('Organisation ID is required but not provided');
49
+ return false;
50
+ }
51
+
52
+ const appOrganisationId = getAppContext()?.account?.organizationId;
53
+ if (customerId !== appOrganisationId) {
54
+ logger.error(`Invalid organisation ID: expected ${appOrganisationId}, received ${customerId}`);
55
+ return false;
56
+ }
57
+
58
+ return true;
59
+ }
60
+
61
+ /**
62
+ * Check if a request should skip authentication (discovery/ready endpoints)
63
+ *
64
+ * @param request - The incoming request
65
+ * @returns true if auth should be skipped
66
+ */
67
+ function shouldSkipAuth(request: any): boolean {
68
+ return request.path === '/discovery' || request.path === '/ready';
69
+ }
70
+
71
+ /**
72
+ * Core authentication flow - extracts auth data and validates token
73
+ *
74
+ * @param request - The incoming request
75
+ * @param validateOrg - Whether to validate organization ID
76
+ * @returns true if authentication succeeds
77
+ */
78
+ async function authenticateRequest(request: any, validateOrg: boolean): Promise<boolean> {
79
+ if (shouldSkipAuth(request)) {
80
+ return true;
81
+ }
82
+
83
+ const authInfo = extractAuthData(request);
84
+ if (!authInfo) {
85
+ logger.error('OptiID token is required but not provided');
86
+ return false;
87
+ }
88
+
89
+ const { authData, accessToken } = authInfo;
90
+
91
+ // Validate organization ID if required
92
+ if (validateOrg && !validateOrganizationId(authData.credentials?.customer_id)) {
93
+ return false;
94
+ }
95
+
96
+ return await validateAccessToken(accessToken);
97
+ }
98
+
99
+ /**
100
+ * Authenticate a request for regular functions (with organization validation)
101
+ *
102
+ * @param request - The incoming request
103
+ * @returns true if authentication and authorization succeed
104
+ */
105
+ export async function authenticateRegularRequest(request: any): Promise<boolean> {
106
+ return await authenticateRequest(request, true);
107
+ }
108
+
109
+ /**
110
+ * Authenticate a request for global functions (without organization validation)
111
+ *
112
+ * @param request - The incoming request
113
+ * @returns true if authentication succeeds
114
+ */
115
+ export async function authenticateGlobalRequest(request: any): Promise<boolean> {
116
+ return await authenticateRequest(request, false);
117
+ }
@@ -1,3 +1,14 @@
1
+
2
+ // Mock the app-sdk module
3
+ jest.mock('@zaiusinc/app-sdk', () => ({
4
+ logger: {
5
+ info: jest.fn(),
6
+ error: jest.fn(),
7
+ warn: jest.fn(),
8
+ debug: jest.fn(),
9
+ },
10
+ }));
11
+
1
12
  import { TokenVerifier } from './TokenVerifier';
2
13
 
3
14
  // Test constants
@@ -92,7 +92,6 @@ export class TokenVerifier {
92
92
  cooldownDuration: DEFAULT_JWKS_EXPIRES_IN
93
93
  });
94
94
  this.initialized = true;
95
- logger.info('TokenVerifier environment ' + environment);
96
95
  logger.info(`TokenVerifier initialized with issuer: ${this.issuer} (environment: ${environment})`);
97
96
  } catch (error) {
98
97
  logger.error('Failed to initialize TokenVerifier', error);
@@ -0,0 +1,505 @@
1
+ import { GlobalToolFunction } from './GlobalToolFunction';
2
+ import { toolsService } from '../service/Service';
3
+ import { Response, getAppContext } from '@zaiusinc/app-sdk';
4
+ import { getTokenVerifier } from '../auth/TokenVerifier';
5
+
6
+ // Mock the dependencies
7
+ jest.mock('../service/Service', () => ({
8
+ toolsService: {
9
+ processRequest: jest.fn(),
10
+ },
11
+ }));
12
+
13
+ jest.mock('../auth/TokenVerifier', () => ({
14
+ getTokenVerifier: jest.fn(),
15
+ }));
16
+
17
+ jest.mock('@zaiusinc/app-sdk', () => ({
18
+ GlobalFunction: class {
19
+ protected request: any;
20
+ public constructor(_name?: string) {
21
+ this.request = {};
22
+ }
23
+ },
24
+ Request: jest.fn().mockImplementation(() => ({})),
25
+ Response: jest.fn().mockImplementation((status, data) => ({
26
+ status,
27
+ data,
28
+ bodyJSON: data,
29
+ bodyAsU8Array: new Uint8Array()
30
+ })),
31
+ amendLogContext: jest.fn(),
32
+ getAppContext: jest.fn(),
33
+ logger: {
34
+ info: jest.fn(),
35
+ error: jest.fn(),
36
+ warn: jest.fn(),
37
+ debug: jest.fn(),
38
+ },
39
+ }));
40
+
41
+ // Create a concrete implementation for testing
42
+ class TestGlobalToolFunction extends GlobalToolFunction {
43
+ private mockReady: jest.MockedFunction<() => Promise<boolean>>;
44
+
45
+ public constructor(request?: any) {
46
+ super(request || {});
47
+ (this as any).request = request;
48
+ this.mockReady = jest.fn().mockResolvedValue(true);
49
+ }
50
+
51
+ // Override the ready method with mock implementation for testing
52
+ protected ready(): Promise<boolean> {
53
+ return this.mockReady();
54
+ }
55
+
56
+ public getRequest() {
57
+ return (this as any).request;
58
+ }
59
+
60
+ public getMockReady() {
61
+ return this.mockReady;
62
+ }
63
+ }
64
+
65
+ describe('GlobalToolFunction', () => {
66
+ let mockRequest: any;
67
+ let mockResponse: Response;
68
+ let globalToolFunction: TestGlobalToolFunction;
69
+ let mockProcessRequest: jest.MockedFunction<typeof toolsService.processRequest>;
70
+ let mockGetTokenVerifier: jest.MockedFunction<typeof getTokenVerifier>;
71
+ let mockGetAppContext: jest.MockedFunction<typeof getAppContext>;
72
+ let mockTokenVerifier: jest.Mocked<{
73
+ verify: (token: string) => Promise<any>;
74
+ }>;
75
+
76
+ beforeEach(() => {
77
+ jest.clearAllMocks();
78
+
79
+ // Create mock token verifier
80
+ mockTokenVerifier = {
81
+ verify: jest.fn(),
82
+ };
83
+
84
+ // Setup the mocks
85
+ mockProcessRequest = jest.mocked(toolsService.processRequest);
86
+ mockGetTokenVerifier = jest.mocked(getTokenVerifier);
87
+ mockGetAppContext = jest.mocked(getAppContext);
88
+
89
+ mockGetTokenVerifier.mockResolvedValue(mockTokenVerifier as any);
90
+ mockGetAppContext.mockReturnValue({
91
+ account: {
92
+ organizationId: 'app-org-123'
93
+ }
94
+ } as any);
95
+
96
+ // Create mock request with bodyJSON structure
97
+ // Note: Global functions don't validate customer_id, so it can be different or missing
98
+ mockRequest = {
99
+ headers: new Map(),
100
+ method: 'POST',
101
+ path: '/test',
102
+ bodyJSON: {
103
+ parameters: {
104
+ task_id: 'task-123',
105
+ content_id: 'content-456'
106
+ },
107
+ auth: {
108
+ provider: 'OptiID',
109
+ credentials: {
110
+ token_type: 'Bearer',
111
+ access_token: 'valid-access-token',
112
+ org_sso_id: 'org-sso-123',
113
+ user_id: 'user-456',
114
+ instance_id: 'instance-789',
115
+ customer_id: 'any-org-123', // Can be different from app org for global functions
116
+ product_sku: 'OPAL'
117
+ }
118
+ },
119
+ environment: {
120
+ execution_mode: 'headless'
121
+ }
122
+ }
123
+ };
124
+
125
+ mockResponse = {} as Response;
126
+ globalToolFunction = new TestGlobalToolFunction(mockRequest);
127
+ });
128
+
129
+ // Helper function to create a ready request with valid auth
130
+ const createReadyRequestWithAuth = () => ({
131
+ headers: new Map(),
132
+ method: 'GET',
133
+ path: '/ready',
134
+ bodyJSON: {
135
+ auth: {
136
+ provider: 'OptiID',
137
+ credentials: {
138
+ access_token: 'valid-token',
139
+ customer_id: 'any-org-123' // Can be any org for global functions
140
+ }
141
+ }
142
+ }
143
+ });
144
+
145
+ // Helper function to create a discovery request
146
+ const createDiscoveryRequest = () => ({
147
+ headers: new Map(),
148
+ method: 'GET',
149
+ path: '/discovery'
150
+ });
151
+
152
+ // Helper function to setup authorization mocks to pass
153
+ const setupAuthMocks = () => {
154
+ mockTokenVerifier.verify.mockResolvedValue(true);
155
+ };
156
+
157
+ describe('/discovery endpoint', () => {
158
+ it('should allow discovery endpoint without authentication', async () => {
159
+ // Arrange
160
+ const discoveryRequest = createDiscoveryRequest();
161
+ globalToolFunction = new TestGlobalToolFunction(discoveryRequest);
162
+ mockProcessRequest.mockResolvedValue(mockResponse);
163
+
164
+ // Act
165
+ const result = await globalToolFunction.perform();
166
+
167
+ // Assert
168
+ expect(result).toBe(mockResponse);
169
+ expect(mockGetTokenVerifier).not.toHaveBeenCalled(); // Should not verify token for discovery
170
+ expect(mockProcessRequest).toHaveBeenCalledWith(discoveryRequest, globalToolFunction);
171
+ });
172
+ });
173
+
174
+ describe('/ready endpoint', () => {
175
+ beforeEach(() => {
176
+ setupAuthMocks();
177
+ });
178
+
179
+ it('should return ready: true when ready method returns true', async () => {
180
+ // Arrange
181
+ const readyRequest = createReadyRequestWithAuth();
182
+ globalToolFunction = new TestGlobalToolFunction(readyRequest);
183
+ globalToolFunction.getMockReady().mockResolvedValue(true);
184
+
185
+ // Act
186
+ const result = await globalToolFunction.perform();
187
+
188
+ // Assert
189
+ expect(globalToolFunction.getMockReady()).toHaveBeenCalledTimes(1);
190
+ expect(result).toEqual(new Response(200, { ready: true }));
191
+ expect(mockProcessRequest).not.toHaveBeenCalled(); // Should not call service
192
+ });
193
+
194
+ it('should return ready: false when ready method returns false', async () => {
195
+ // Arrange
196
+ const readyRequest = createReadyRequestWithAuth();
197
+ globalToolFunction = new TestGlobalToolFunction(readyRequest);
198
+ globalToolFunction.getMockReady().mockResolvedValue(false);
199
+
200
+ // Act
201
+ const result = await globalToolFunction.perform();
202
+
203
+ // Assert
204
+ expect(globalToolFunction.getMockReady()).toHaveBeenCalledTimes(1);
205
+ expect(result).toEqual(new Response(200, { ready: false }));
206
+ expect(mockProcessRequest).not.toHaveBeenCalled(); // Should not call service
207
+ });
208
+
209
+ it('should handle ready method throwing an error', async () => {
210
+ // Arrange
211
+ const readyRequest = createReadyRequestWithAuth();
212
+ globalToolFunction = new TestGlobalToolFunction(readyRequest);
213
+ globalToolFunction.getMockReady().mockRejectedValue(new Error('Ready check failed'));
214
+
215
+ // Act & Assert
216
+ await expect(globalToolFunction.perform()).rejects.toThrow('Ready check failed');
217
+ expect(globalToolFunction.getMockReady()).toHaveBeenCalledTimes(1);
218
+ expect(mockProcessRequest).not.toHaveBeenCalled(); // Should not call service
219
+ });
220
+
221
+ it('should use default ready implementation when not overridden', async () => {
222
+ // Create a class that doesn't override ready method
223
+ class DefaultReadyGlobalToolFunction extends GlobalToolFunction {
224
+ public constructor(request?: any) {
225
+ super(request || {});
226
+ (this as any).request = request;
227
+ }
228
+
229
+ public getRequest() {
230
+ return (this as any).request;
231
+ }
232
+ }
233
+
234
+ // Arrange
235
+ const readyRequest = createReadyRequestWithAuth();
236
+ const defaultGlobalToolFunction = new DefaultReadyGlobalToolFunction(readyRequest);
237
+
238
+ // Act
239
+ const result = await defaultGlobalToolFunction.perform();
240
+
241
+ // Assert - Default implementation should return true
242
+ expect(result).toEqual(new Response(200, { ready: true }));
243
+ expect(mockProcessRequest).not.toHaveBeenCalled(); // Should not call service
244
+ });
245
+
246
+ it('should allow ready endpoint without authentication', async () => {
247
+ // Arrange
248
+ const readyRequestWithoutAuth = {
249
+ headers: new Map(),
250
+ method: 'GET',
251
+ path: '/ready'
252
+ };
253
+ globalToolFunction = new TestGlobalToolFunction(readyRequestWithoutAuth);
254
+ globalToolFunction.getMockReady().mockResolvedValue(true);
255
+
256
+ // Act
257
+ const result = await globalToolFunction.perform();
258
+
259
+ // Assert
260
+ expect(result).toEqual(new Response(200, { ready: true }));
261
+ expect(mockGetTokenVerifier).not.toHaveBeenCalled(); // Should not verify token for ready
262
+ expect(mockProcessRequest).not.toHaveBeenCalled(); // Should not call service
263
+ });
264
+ });
265
+
266
+ describe('perform', () => {
267
+ it('should execute successfully with valid token (no organization validation)', async () => {
268
+ // Setup mock token verifier to return true for valid token
269
+ mockTokenVerifier.verify.mockResolvedValue(true);
270
+ mockProcessRequest.mockResolvedValue(mockResponse);
271
+
272
+ const result = await globalToolFunction.perform();
273
+
274
+ expect(result).toBe(mockResponse);
275
+ expect(mockGetTokenVerifier).toHaveBeenCalled();
276
+ expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
277
+ // Note: getAppContext should NOT be called for global functions
278
+ expect(mockGetAppContext).not.toHaveBeenCalled();
279
+ expect(mockProcessRequest).toHaveBeenCalledWith(mockRequest, globalToolFunction);
280
+ });
281
+
282
+ it('should execute successfully even with different organization ID', async () => {
283
+ // Update mock request with different customer_id (should still work for global functions)
284
+ const requestWithDifferentOrgId = {
285
+ ...mockRequest,
286
+ bodyJSON: {
287
+ ...mockRequest.bodyJSON,
288
+ auth: {
289
+ ...mockRequest.bodyJSON.auth,
290
+ credentials: {
291
+ ...mockRequest.bodyJSON.auth.credentials,
292
+ customer_id: 'completely-different-org-456'
293
+ }
294
+ }
295
+ }
296
+ };
297
+
298
+ const globalToolFunctionWithDifferentOrgId = new TestGlobalToolFunction(requestWithDifferentOrgId);
299
+ mockTokenVerifier.verify.mockResolvedValue(true);
300
+ mockProcessRequest.mockResolvedValue(mockResponse);
301
+
302
+ const result = await globalToolFunctionWithDifferentOrgId.perform();
303
+
304
+ expect(result).toBe(mockResponse);
305
+ expect(mockGetTokenVerifier).toHaveBeenCalled();
306
+ expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
307
+ // Note: Should NOT validate organization ID for global functions
308
+ expect(mockGetAppContext).not.toHaveBeenCalled();
309
+ expect(mockProcessRequest).toHaveBeenCalledWith(requestWithDifferentOrgId, globalToolFunctionWithDifferentOrgId);
310
+ });
311
+
312
+ it('should execute successfully even without customer_id', async () => {
313
+ // Create request without customer_id (should still work for global functions)
314
+ const requestWithoutCustomerId = {
315
+ ...mockRequest,
316
+ bodyJSON: {
317
+ ...mockRequest.bodyJSON,
318
+ auth: {
319
+ ...mockRequest.bodyJSON.auth,
320
+ credentials: {
321
+ ...mockRequest.bodyJSON.auth.credentials,
322
+ customer_id: undefined
323
+ }
324
+ }
325
+ }
326
+ };
327
+
328
+ const globalToolFunctionWithoutCustomerId = new TestGlobalToolFunction(requestWithoutCustomerId);
329
+ mockTokenVerifier.verify.mockResolvedValue(true);
330
+ mockProcessRequest.mockResolvedValue(mockResponse);
331
+
332
+ const result = await globalToolFunctionWithoutCustomerId.perform();
333
+
334
+ expect(result).toBe(mockResponse);
335
+ expect(mockGetTokenVerifier).toHaveBeenCalled();
336
+ expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
337
+ expect(mockGetAppContext).not.toHaveBeenCalled();
338
+ expect(mockProcessRequest).toHaveBeenCalledWith(requestWithoutCustomerId, globalToolFunctionWithoutCustomerId);
339
+ });
340
+
341
+ it('should return 403 response with invalid token', async () => {
342
+ // Setup mock token verifier to return false
343
+ mockTokenVerifier.verify.mockResolvedValue(false);
344
+
345
+ const result = await globalToolFunction.perform();
346
+
347
+ expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
348
+ expect(mockGetTokenVerifier).toHaveBeenCalled();
349
+ expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
350
+ expect(mockProcessRequest).not.toHaveBeenCalled();
351
+ });
352
+
353
+ it('should return 403 response when access token is missing', async () => {
354
+ // Create request without access token
355
+ const requestWithoutToken = {
356
+ ...mockRequest,
357
+ bodyJSON: {
358
+ ...mockRequest.bodyJSON,
359
+ auth: {
360
+ ...mockRequest.bodyJSON.auth,
361
+ credentials: {
362
+ ...mockRequest.bodyJSON.auth.credentials,
363
+ access_token: undefined
364
+ }
365
+ }
366
+ }
367
+ };
368
+
369
+ const globalToolFunctionWithoutToken = new TestGlobalToolFunction(requestWithoutToken);
370
+
371
+ const result = await globalToolFunctionWithoutToken.perform();
372
+
373
+ expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
374
+ expect(mockGetTokenVerifier).not.toHaveBeenCalled();
375
+ expect(mockProcessRequest).not.toHaveBeenCalled();
376
+ });
377
+
378
+ it('should return 403 response when provider is not OptiID', async () => {
379
+ // Create request with different provider
380
+ const requestWithDifferentProvider = {
381
+ ...mockRequest,
382
+ bodyJSON: {
383
+ ...mockRequest.bodyJSON,
384
+ auth: {
385
+ ...mockRequest.bodyJSON.auth,
386
+ provider: 'SomeOtherProvider'
387
+ }
388
+ }
389
+ };
390
+
391
+ const globalToolFunctionWithDifferentProvider = new TestGlobalToolFunction(requestWithDifferentProvider);
392
+
393
+ const result = await globalToolFunctionWithDifferentProvider.perform();
394
+
395
+ expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
396
+ expect(mockGetTokenVerifier).not.toHaveBeenCalled();
397
+ expect(mockProcessRequest).not.toHaveBeenCalled();
398
+ });
399
+
400
+ it('should return 403 response when auth structure is missing', async () => {
401
+ // Create request without auth structure
402
+ const requestWithoutAuth = {
403
+ ...mockRequest,
404
+ bodyJSON: {
405
+ parameters: mockRequest.bodyJSON.parameters,
406
+ environment: mockRequest.bodyJSON.environment
407
+ }
408
+ };
409
+
410
+ const globalToolFunctionWithoutAuth = new TestGlobalToolFunction(requestWithoutAuth);
411
+
412
+ const result = await globalToolFunctionWithoutAuth.perform();
413
+
414
+ expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
415
+ expect(mockGetTokenVerifier).not.toHaveBeenCalled();
416
+ expect(mockProcessRequest).not.toHaveBeenCalled();
417
+ });
418
+
419
+ it('should return 403 response when token verifier initialization fails', async () => {
420
+ // Setup mock to fail during token verifier initialization
421
+ mockGetTokenVerifier.mockRejectedValue(new Error('Failed to initialize token verifier'));
422
+
423
+ const result = await globalToolFunction.perform();
424
+
425
+ expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
426
+ expect(mockGetTokenVerifier).toHaveBeenCalled();
427
+ expect(mockProcessRequest).not.toHaveBeenCalled();
428
+ });
429
+
430
+ it('should return 403 response when token validation throws an error', async () => {
431
+ // Setup mock token verifier to throw an error
432
+ mockTokenVerifier.verify.mockRejectedValue(new Error('Token validation failed'));
433
+
434
+ const result = await globalToolFunction.perform();
435
+
436
+ expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
437
+ expect(mockGetTokenVerifier).toHaveBeenCalled();
438
+ expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
439
+ expect(mockProcessRequest).not.toHaveBeenCalled();
440
+ });
441
+ });
442
+
443
+ describe('inheritance', () => {
444
+ it('should be an instance of GlobalFunction', () => {
445
+ // Assert
446
+ expect(globalToolFunction).toBeInstanceOf(GlobalToolFunction);
447
+ });
448
+
449
+ it('should have access to the request property', () => {
450
+ // Assert
451
+ expect(globalToolFunction.getRequest()).toBe(mockRequest);
452
+ });
453
+ });
454
+
455
+ describe('authentication differences from ToolFunction', () => {
456
+ it('should NOT validate organization ID (unlike ToolFunction)', async () => {
457
+ // This test demonstrates the key difference between GlobalToolFunction and ToolFunction
458
+ // GlobalToolFunction should work with any customer_id, while ToolFunction requires matching org ID
459
+
460
+ const requestWithRandomOrgId = {
461
+ ...mockRequest,
462
+ bodyJSON: {
463
+ ...mockRequest.bodyJSON,
464
+ auth: {
465
+ ...mockRequest.bodyJSON.auth,
466
+ credentials: {
467
+ ...mockRequest.bodyJSON.auth.credentials,
468
+ customer_id: 'random-org-999' // This would fail in ToolFunction but should work here
469
+ }
470
+ }
471
+ }
472
+ };
473
+
474
+ const globalToolFunctionWithRandomOrgId = new TestGlobalToolFunction(requestWithRandomOrgId);
475
+ mockTokenVerifier.verify.mockResolvedValue(true);
476
+ mockProcessRequest.mockResolvedValue(mockResponse);
477
+
478
+ const result = await globalToolFunctionWithRandomOrgId.perform();
479
+
480
+ // Should succeed even with different org ID
481
+ expect(result).toBe(mockResponse);
482
+ expect(mockGetTokenVerifier).toHaveBeenCalled();
483
+ expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
484
+ // Crucially: getAppContext should NOT be called (no org validation)
485
+ expect(mockGetAppContext).not.toHaveBeenCalled();
486
+ expect(mockProcessRequest).toHaveBeenCalledWith(requestWithRandomOrgId, globalToolFunctionWithRandomOrgId);
487
+ });
488
+
489
+ it('should only require valid OptiID token (no organization constraints)', async () => {
490
+ // Test that only token validation is performed, no org validation
491
+ mockTokenVerifier.verify.mockResolvedValue(true);
492
+ mockProcessRequest.mockResolvedValue(mockResponse);
493
+
494
+ const result = await globalToolFunction.perform();
495
+
496
+ expect(result).toBe(mockResponse);
497
+ expect(mockGetTokenVerifier).toHaveBeenCalled();
498
+ expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
499
+
500
+ // Key assertion: getAppContext should never be called for global functions
501
+ expect(mockGetAppContext).not.toHaveBeenCalled();
502
+ expect(mockProcessRequest).toHaveBeenCalledWith(mockRequest, globalToolFunction);
503
+ });
504
+ });
505
+ });
@@ -0,0 +1,56 @@
1
+ import { GlobalFunction, Response, amendLogContext } from '@zaiusinc/app-sdk';
2
+ import { authenticateGlobalRequest, extractAuthData } from '../auth/AuthUtils';
3
+ import { toolsService } from '../service/Service';
4
+
5
+ /**
6
+ * Abstract base class for global tool-based function execution
7
+ * Provides a standard interface for processing requests through registered tools
8
+ */
9
+ export abstract class GlobalToolFunction extends GlobalFunction {
10
+
11
+ /**
12
+ * Override this method to implement any required credentials and/or other configuration
13
+ * exist and are valid. Reasonable caching should be utilized to prevent excessive requests to external resources.
14
+ * @async
15
+ * @returns true if the opal function is ready to use
16
+ */
17
+ protected ready(): Promise<boolean> {
18
+ return Promise.resolve(true);
19
+ }
20
+
21
+ /**
22
+ * Process the incoming request using the tools service
23
+ *
24
+ * @returns Response as the HTTP response
25
+ */
26
+ public async perform(): Promise<Response> {
27
+ // Extract customer_id from auth data for global context attribution
28
+ const authInfo = extractAuthData(this.request);
29
+ const customerId = authInfo?.authData?.credentials?.customer_id;
30
+
31
+ amendLogContext({
32
+ opalThreadId: this.request.headers.get('x-opal-thread-id') || '',
33
+ customerId: customerId || ''
34
+ });
35
+
36
+ if (!(await this.authorizeRequest())) {
37
+ return new Response(403, { error: 'Forbidden' });
38
+ }
39
+
40
+ if (this.request.path === '/ready') {
41
+ const isReady = await this.ready();
42
+ return new Response(200, { ready: isReady });
43
+ }
44
+
45
+ return toolsService.processRequest(this.request, this);
46
+ }
47
+
48
+ /**
49
+ * Authenticate the incoming request by validating only the OptiID token
50
+ *
51
+ * @returns true if authentication succeeds
52
+ */
53
+ private async authorizeRequest(): Promise<boolean> {
54
+ return await authenticateGlobalRequest(this.request);
55
+ }
56
+ }