@optimizely-opal/opal-tool-ocp-sdk 1.0.0-beta.3 → 1.0.0-beta.5

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 (57) hide show
  1. package/README.md +114 -0
  2. package/dist/auth/AuthUtils.d.ts +5 -5
  3. package/dist/auth/AuthUtils.d.ts.map +1 -1
  4. package/dist/auth/AuthUtils.js +53 -25
  5. package/dist/auth/AuthUtils.js.map +1 -1
  6. package/dist/auth/AuthUtils.test.js +62 -117
  7. package/dist/auth/AuthUtils.test.js.map +1 -1
  8. package/dist/function/GlobalToolFunction.d.ts +1 -1
  9. package/dist/function/GlobalToolFunction.d.ts.map +1 -1
  10. package/dist/function/GlobalToolFunction.js +17 -4
  11. package/dist/function/GlobalToolFunction.js.map +1 -1
  12. package/dist/function/GlobalToolFunction.test.js +54 -8
  13. package/dist/function/GlobalToolFunction.test.js.map +1 -1
  14. package/dist/function/ToolFunction.d.ts +1 -1
  15. package/dist/function/ToolFunction.d.ts.map +1 -1
  16. package/dist/function/ToolFunction.js +17 -4
  17. package/dist/function/ToolFunction.js.map +1 -1
  18. package/dist/function/ToolFunction.test.js +54 -8
  19. package/dist/function/ToolFunction.test.js.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/service/Service.d.ts +15 -2
  25. package/dist/service/Service.d.ts.map +1 -1
  26. package/dist/service/Service.js +43 -17
  27. package/dist/service/Service.js.map +1 -1
  28. package/dist/service/Service.test.js +84 -2
  29. package/dist/service/Service.test.js.map +1 -1
  30. package/dist/types/ToolError.d.ts +72 -0
  31. package/dist/types/ToolError.d.ts.map +1 -0
  32. package/dist/types/ToolError.js +107 -0
  33. package/dist/types/ToolError.js.map +1 -0
  34. package/dist/types/ToolError.test.d.ts +2 -0
  35. package/dist/types/ToolError.test.d.ts.map +1 -0
  36. package/dist/types/ToolError.test.js +185 -0
  37. package/dist/types/ToolError.test.js.map +1 -0
  38. package/dist/validation/ParameterValidator.d.ts +5 -16
  39. package/dist/validation/ParameterValidator.d.ts.map +1 -1
  40. package/dist/validation/ParameterValidator.js +10 -3
  41. package/dist/validation/ParameterValidator.js.map +1 -1
  42. package/dist/validation/ParameterValidator.test.js +187 -146
  43. package/dist/validation/ParameterValidator.test.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/auth/AuthUtils.test.ts +62 -157
  46. package/src/auth/AuthUtils.ts +66 -32
  47. package/src/function/GlobalToolFunction.test.ts +54 -8
  48. package/src/function/GlobalToolFunction.ts +26 -6
  49. package/src/function/ToolFunction.test.ts +54 -8
  50. package/src/function/ToolFunction.ts +26 -6
  51. package/src/index.ts +1 -0
  52. package/src/service/Service.test.ts +103 -2
  53. package/src/service/Service.ts +45 -17
  54. package/src/types/ToolError.test.ts +222 -0
  55. package/src/types/ToolError.ts +125 -0
  56. package/src/validation/ParameterValidator.test.ts +188 -158
  57. package/src/validation/ParameterValidator.ts +17 -20
@@ -1,23 +1,30 @@
1
1
  import { getAppContext, logger } from '@zaiusinc/app-sdk';
2
2
  import { getTokenVerifier } from './TokenVerifier';
3
3
  import { OptiIdAuthData } from '../types/Models';
4
+ import { ToolError } from '../types/ToolError';
4
5
 
5
6
  /**
6
7
  * Validate the OptiID access token
7
8
  *
8
9
  * @param accessToken - The access token to validate
9
- * @returns true if the token is valid
10
+ * @throws {ToolError} If token validation fails
10
11
  */
11
- async function validateAccessToken(accessToken: string | undefined): Promise<boolean> {
12
+ async function validateAccessToken(accessToken: string | undefined): Promise<void> {
12
13
  try {
13
14
  if (!accessToken) {
14
- return false;
15
+ throw new ToolError('Forbidden', 403, 'OptiID access token is required');
15
16
  }
16
17
  const tokenVerifier = await getTokenVerifier();
17
- return await tokenVerifier.verify(accessToken);
18
+ const isValid = await tokenVerifier.verify(accessToken);
19
+ if (!isValid) {
20
+ throw new ToolError('Forbidden', 403, 'Invalid OptiID access token');
21
+ }
18
22
  } catch (error) {
23
+ if (error instanceof ToolError) {
24
+ throw error;
25
+ }
19
26
  logger.error('OptiID token validation failed:', error);
20
- return false;
27
+ throw new ToolError('Forbidden', 403, 'Token verification failed');
21
28
  }
22
29
  }
23
30
 
@@ -25,37 +32,70 @@ async function validateAccessToken(accessToken: string | undefined): Promise<boo
25
32
  * Extract and validate basic OptiID authentication data from request
26
33
  *
27
34
  * @param request - The incoming request
28
- * @returns object with authData and accessToken, or null if invalid
35
+ * @returns object with authData and accessToken, or null if auth is missing
29
36
  */
30
37
  export function extractAuthData(request: any): { authData: OptiIdAuthData; accessToken: string } | null {
31
38
  const authData = request?.bodyJSON?.auth as OptiIdAuthData;
39
+
40
+ if (!authData) {
41
+ return null;
42
+ }
43
+
44
+ if (authData?.provider?.toLowerCase() !== 'optiid') {
45
+ return null;
46
+ }
47
+
32
48
  const accessToken = authData?.credentials?.access_token;
33
- if (!accessToken || authData?.provider?.toLowerCase() !== 'optiid') {
49
+ if (!accessToken) {
34
50
  return null;
35
51
  }
36
52
 
37
53
  return { authData, accessToken };
38
54
  }
39
55
 
56
+ /**
57
+ * Extract and validate auth data with error throwing for authentication flow
58
+ *
59
+ * @param request - The incoming request
60
+ * @returns object with authData and accessToken
61
+ * @throws {ToolError} If auth data is invalid or missing
62
+ */
63
+ function extractAndValidateAuthData(request: any): { authData: OptiIdAuthData; accessToken: string } {
64
+ const authData = request?.bodyJSON?.auth as OptiIdAuthData;
65
+
66
+ if (!authData) {
67
+ throw new ToolError('Forbidden', 403, 'Authentication data is required');
68
+ }
69
+
70
+ if (authData?.provider?.toLowerCase() !== 'optiid') {
71
+ throw new ToolError('Forbidden', 403, 'Only OptiID authentication provider is supported');
72
+ }
73
+
74
+ const accessToken = authData?.credentials?.access_token;
75
+ if (!accessToken) {
76
+ throw new ToolError('Forbidden', 403, 'OptiID access token is required');
77
+ }
78
+
79
+ return { authData, accessToken };
80
+ }
81
+
40
82
  /**
41
83
  * Validate organization ID matches the app context
42
84
  *
43
85
  * @param customerId - The customer ID from the auth data
44
- * @returns true if the organization ID is valid
86
+ * @throws {ToolError} If organization ID is invalid or missing
45
87
  */
46
- function validateOrganizationId(customerId: string | undefined): boolean {
88
+ function validateOrganizationId(customerId: string | undefined): void {
47
89
  if (!customerId) {
48
90
  logger.error('Organisation ID is required but not provided');
49
- return false;
91
+ throw new ToolError('Forbidden', 403, 'Organization ID is required');
50
92
  }
51
93
 
52
94
  const appOrganisationId = getAppContext()?.account?.organizationId;
53
95
  if (customerId !== appOrganisationId) {
54
96
  logger.error(`Invalid organisation ID: expected ${appOrganisationId}, received ${customerId}`);
55
- return false;
97
+ throw new ToolError('Forbidden', 403, 'Organization ID does not match');
56
98
  }
57
-
58
- return true;
59
99
  }
60
100
 
61
101
  /**
@@ -73,45 +113,39 @@ function shouldSkipAuth(request: any): boolean {
73
113
  *
74
114
  * @param request - The incoming request
75
115
  * @param validateOrg - Whether to validate organization ID
76
- * @returns true if authentication succeeds
116
+ * @throws {ToolError} If authentication fails
77
117
  */
78
- async function authenticateRequest(request: any, validateOrg: boolean): Promise<boolean> {
118
+ async function authenticateRequest(request: any, validateOrg: boolean): Promise<void> {
79
119
  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;
120
+ return;
87
121
  }
88
122
 
89
- const { authData, accessToken } = authInfo;
123
+ const { authData, accessToken } = extractAndValidateAuthData(request);
90
124
 
91
125
  // Validate organization ID if required
92
- if (validateOrg && !validateOrganizationId(authData.credentials?.customer_id)) {
93
- return false;
126
+ if (validateOrg) {
127
+ validateOrganizationId(authData.credentials?.customer_id);
94
128
  }
95
129
 
96
- return await validateAccessToken(accessToken);
130
+ await validateAccessToken(accessToken);
97
131
  }
98
132
 
99
133
  /**
100
134
  * Authenticate a request for regular functions (with organization validation)
101
135
  *
102
136
  * @param request - The incoming request
103
- * @returns true if authentication and authorization succeed
137
+ * @throws {ToolError} If authentication or authorization fails
104
138
  */
105
- export async function authenticateRegularRequest(request: any): Promise<boolean> {
106
- return await authenticateRequest(request, true);
139
+ export async function authenticateRegularRequest(request: any): Promise<void> {
140
+ await authenticateRequest(request, true);
107
141
  }
108
142
 
109
143
  /**
110
144
  * Authenticate a request for global functions (without organization validation)
111
145
  *
112
146
  * @param request - The incoming request
113
- * @returns true if authentication succeeds
147
+ * @throws {ToolError} If authentication fails
114
148
  */
115
- export async function authenticateGlobalRequest(request: any): Promise<boolean> {
116
- return await authenticateRequest(request, false);
149
+ export async function authenticateGlobalRequest(request: any): Promise<void> {
150
+ await authenticateRequest(request, false);
117
151
  }
@@ -22,12 +22,22 @@ jest.mock('@zaiusinc/app-sdk', () => ({
22
22
  }
23
23
  },
24
24
  Request: jest.fn().mockImplementation(() => ({})),
25
- Response: jest.fn().mockImplementation((status, data) => ({
25
+ Response: jest.fn().mockImplementation((status, data, headers) => ({
26
26
  status,
27
27
  data,
28
28
  bodyJSON: data,
29
- bodyAsU8Array: new Uint8Array()
29
+ bodyAsU8Array: new Uint8Array(),
30
+ headers
30
31
  })),
32
+ Headers: jest.fn().mockImplementation((entries) => {
33
+ const headers = new Map();
34
+ if (entries) {
35
+ for (const [key, value] of entries) {
36
+ headers.set(key, value);
37
+ }
38
+ }
39
+ return headers;
40
+ }),
31
41
  amendLogContext: jest.fn(),
32
42
  getAppContext: jest.fn(),
33
43
  logger: {
@@ -347,7 +357,13 @@ describe('GlobalToolFunction', () => {
347
357
 
348
358
  const result = await globalToolFunction.perform();
349
359
 
350
- expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
360
+ expect(result.status).toBe(403);
361
+ expect(result.bodyJSON).toEqual({
362
+ title: 'Forbidden',
363
+ status: 403,
364
+ detail: 'Invalid OptiID access token',
365
+ instance: '/test'
366
+ });
351
367
  expect(mockGetTokenVerifier).toHaveBeenCalled();
352
368
  expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
353
369
  expect(mockProcessRequest).not.toHaveBeenCalled();
@@ -373,7 +389,13 @@ describe('GlobalToolFunction', () => {
373
389
 
374
390
  const result = await globalToolFunctionWithoutToken.perform();
375
391
 
376
- expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
392
+ expect(result.status).toBe(403);
393
+ expect(result.bodyJSON).toEqual({
394
+ title: 'Forbidden',
395
+ status: 403,
396
+ detail: 'OptiID access token is required',
397
+ instance: '/test'
398
+ });
377
399
  expect(mockGetTokenVerifier).not.toHaveBeenCalled();
378
400
  expect(mockProcessRequest).not.toHaveBeenCalled();
379
401
  });
@@ -395,7 +417,13 @@ describe('GlobalToolFunction', () => {
395
417
 
396
418
  const result = await globalToolFunctionWithDifferentProvider.perform();
397
419
 
398
- expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
420
+ expect(result.status).toBe(403);
421
+ expect(result.bodyJSON).toEqual({
422
+ title: 'Forbidden',
423
+ status: 403,
424
+ detail: 'Only OptiID authentication provider is supported',
425
+ instance: '/test'
426
+ });
399
427
  expect(mockGetTokenVerifier).not.toHaveBeenCalled();
400
428
  expect(mockProcessRequest).not.toHaveBeenCalled();
401
429
  });
@@ -414,7 +442,13 @@ describe('GlobalToolFunction', () => {
414
442
 
415
443
  const result = await globalToolFunctionWithoutAuth.perform();
416
444
 
417
- expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
445
+ expect(result.status).toBe(403);
446
+ expect(result.bodyJSON).toEqual({
447
+ title: 'Forbidden',
448
+ status: 403,
449
+ detail: 'Authentication data is required',
450
+ instance: '/test'
451
+ });
418
452
  expect(mockGetTokenVerifier).not.toHaveBeenCalled();
419
453
  expect(mockProcessRequest).not.toHaveBeenCalled();
420
454
  });
@@ -425,7 +459,13 @@ describe('GlobalToolFunction', () => {
425
459
 
426
460
  const result = await globalToolFunction.perform();
427
461
 
428
- expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
462
+ expect(result.status).toBe(403);
463
+ expect(result.bodyJSON).toEqual({
464
+ title: 'Forbidden',
465
+ status: 403,
466
+ detail: 'Token verification failed',
467
+ instance: '/test'
468
+ });
429
469
  expect(mockGetTokenVerifier).toHaveBeenCalled();
430
470
  expect(mockProcessRequest).not.toHaveBeenCalled();
431
471
  });
@@ -436,7 +476,13 @@ describe('GlobalToolFunction', () => {
436
476
 
437
477
  const result = await globalToolFunction.perform();
438
478
 
439
- expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
479
+ expect(result.status).toBe(403);
480
+ expect(result.bodyJSON).toEqual({
481
+ title: 'Forbidden',
482
+ status: 403,
483
+ detail: 'Token verification failed',
484
+ instance: '/test'
485
+ });
440
486
  expect(mockGetTokenVerifier).toHaveBeenCalled();
441
487
  expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
442
488
  expect(mockProcessRequest).not.toHaveBeenCalled();
@@ -1,7 +1,8 @@
1
- import { GlobalFunction, Response, amendLogContext } from '@zaiusinc/app-sdk';
1
+ import { GlobalFunction, Response, Headers, amendLogContext } from '@zaiusinc/app-sdk';
2
2
  import { authenticateGlobalRequest, extractAuthData } from '../auth/AuthUtils';
3
3
  import { toolsService } from '../service/Service';
4
4
  import { ToolLogger } from '../logging/ToolLogger';
5
+ import { ToolError } from '../types/ToolError';
5
6
 
6
7
  /**
7
8
  * Abstract base class for global tool-based function execution
@@ -44,8 +45,27 @@ export abstract class GlobalToolFunction extends GlobalFunction {
44
45
  }
45
46
 
46
47
  private async handleRequest(): Promise<Response> {
47
- if (!(await this.authorizeRequest())) {
48
- return new Response(403, { error: 'Forbidden' });
48
+ try {
49
+ await this.authorizeRequest();
50
+ } catch (error) {
51
+ if (error instanceof ToolError) {
52
+ return new Response(
53
+ error.status,
54
+ error.toProblemDetails(this.request.path),
55
+ new Headers([['content-type', 'application/problem+json']])
56
+ );
57
+ }
58
+ // Fallback for unexpected errors
59
+ return new Response(
60
+ 500,
61
+ {
62
+ title: 'Internal Server Error',
63
+ status: 500,
64
+ detail: 'An unexpected error occurred during authentication',
65
+ instance: this.request.path
66
+ },
67
+ new Headers([['content-type', 'application/problem+json']])
68
+ );
49
69
  }
50
70
 
51
71
  if (this.request.path === '/ready') {
@@ -59,9 +79,9 @@ export abstract class GlobalToolFunction extends GlobalFunction {
59
79
  /**
60
80
  * Authenticate the incoming request by validating only the OptiID token
61
81
  *
62
- * @returns true if authentication succeeds
82
+ * @throws {ToolError} If authentication fails
63
83
  */
64
- private async authorizeRequest(): Promise<boolean> {
65
- return await authenticateGlobalRequest(this.request);
84
+ private async authorizeRequest(): Promise<void> {
85
+ await authenticateGlobalRequest(this.request);
66
86
  }
67
87
  }
@@ -22,12 +22,22 @@ jest.mock('@zaiusinc/app-sdk', () => ({
22
22
  }
23
23
  },
24
24
  Request: jest.fn().mockImplementation(() => ({})),
25
- Response: jest.fn().mockImplementation((status, data) => ({
25
+ Response: jest.fn().mockImplementation((status, data, headers) => ({
26
26
  status,
27
27
  data,
28
28
  bodyJSON: data,
29
- bodyAsU8Array: new Uint8Array()
29
+ bodyAsU8Array: new Uint8Array(),
30
+ headers
30
31
  })),
32
+ Headers: jest.fn().mockImplementation((entries) => {
33
+ const headers = new Map();
34
+ if (entries) {
35
+ for (const [key, value] of entries) {
36
+ headers.set(key, value);
37
+ }
38
+ }
39
+ return headers;
40
+ }),
31
41
  amendLogContext: jest.fn(),
32
42
  getAppContext: jest.fn(),
33
43
  logger: {
@@ -251,7 +261,13 @@ describe('ToolFunction', () => {
251
261
 
252
262
  const result = await toolFunction.perform();
253
263
 
254
- expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
264
+ expect(result.status).toBe(403);
265
+ expect(result.bodyJSON).toEqual({
266
+ title: 'Forbidden',
267
+ status: 403,
268
+ detail: 'Invalid OptiID access token',
269
+ instance: '/test'
270
+ });
255
271
  expect(mockGetTokenVerifier).toHaveBeenCalled();
256
272
  expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
257
273
  expect(mockProcessRequest).not.toHaveBeenCalled();
@@ -277,7 +293,13 @@ describe('ToolFunction', () => {
277
293
 
278
294
  const result = await toolFunctionWithDifferentOrgId.perform();
279
295
 
280
- expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
296
+ expect(result.status).toBe(403);
297
+ expect(result.bodyJSON).toEqual({
298
+ title: 'Forbidden',
299
+ status: 403,
300
+ detail: 'Organization ID does not match',
301
+ instance: '/test'
302
+ });
281
303
  expect(mockGetAppContext).toHaveBeenCalled();
282
304
  expect(mockProcessRequest).not.toHaveBeenCalled();
283
305
  });
@@ -302,7 +324,13 @@ describe('ToolFunction', () => {
302
324
 
303
325
  const result = await toolFunctionWithoutToken.perform();
304
326
 
305
- expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
327
+ expect(result.status).toBe(403);
328
+ expect(result.bodyJSON).toEqual({
329
+ title: 'Forbidden',
330
+ status: 403,
331
+ detail: 'OptiID access token is required',
332
+ instance: '/test'
333
+ });
306
334
  expect(mockGetTokenVerifier).not.toHaveBeenCalled();
307
335
  expect(mockProcessRequest).not.toHaveBeenCalled();
308
336
  });
@@ -327,7 +355,13 @@ describe('ToolFunction', () => {
327
355
 
328
356
  const result = await toolFunctionWithoutCustomerId.perform();
329
357
 
330
- expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
358
+ expect(result.status).toBe(403);
359
+ expect(result.bodyJSON).toEqual({
360
+ title: 'Forbidden',
361
+ status: 403,
362
+ detail: 'Organization ID is required',
363
+ instance: '/test'
364
+ });
331
365
  expect(mockGetTokenVerifier).not.toHaveBeenCalled();
332
366
  expect(mockProcessRequest).not.toHaveBeenCalled();
333
367
  });
@@ -346,7 +380,13 @@ describe('ToolFunction', () => {
346
380
 
347
381
  const result = await toolFunctionWithoutAuth.perform();
348
382
 
349
- expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
383
+ expect(result.status).toBe(403);
384
+ expect(result.bodyJSON).toEqual({
385
+ title: 'Forbidden',
386
+ status: 403,
387
+ detail: 'Authentication data is required',
388
+ instance: '/test'
389
+ });
350
390
  expect(mockGetTokenVerifier).not.toHaveBeenCalled();
351
391
  expect(mockProcessRequest).not.toHaveBeenCalled();
352
392
  });
@@ -357,7 +397,13 @@ describe('ToolFunction', () => {
357
397
 
358
398
  const result = await toolFunction.perform();
359
399
 
360
- expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
400
+ expect(result.status).toBe(403);
401
+ expect(result.bodyJSON).toEqual({
402
+ title: 'Forbidden',
403
+ status: 403,
404
+ detail: 'Token verification failed',
405
+ instance: '/test'
406
+ });
361
407
  expect(mockGetTokenVerifier).toHaveBeenCalled();
362
408
  expect(mockProcessRequest).not.toHaveBeenCalled();
363
409
  });
@@ -1,7 +1,8 @@
1
- import { Function, Response, amendLogContext } from '@zaiusinc/app-sdk';
1
+ import { Function, Response, Headers, amendLogContext } from '@zaiusinc/app-sdk';
2
2
  import { authenticateRegularRequest } from '../auth/AuthUtils';
3
3
  import { toolsService } from '../service/Service';
4
4
  import { ToolLogger } from '../logging/ToolLogger';
5
+ import { ToolError } from '../types/ToolError';
5
6
 
6
7
  /**
7
8
  * Abstract base class for tool-based function execution
@@ -42,8 +43,27 @@ export abstract class ToolFunction extends Function {
42
43
  * @returns Response as the HTTP response
43
44
  */
44
45
  private async handleRequest(): Promise<Response> {
45
- if (!(await this.authorizeRequest())) {
46
- return new Response(403, { error: 'Forbidden' });
46
+ try {
47
+ await this.authorizeRequest();
48
+ } catch (error) {
49
+ if (error instanceof ToolError) {
50
+ return new Response(
51
+ error.status,
52
+ error.toProblemDetails(this.request.path),
53
+ new Headers([['content-type', 'application/problem+json']])
54
+ );
55
+ }
56
+ // Fallback for unexpected errors
57
+ return new Response(
58
+ 500,
59
+ {
60
+ title: 'Internal Server Error',
61
+ status: 500,
62
+ detail: 'An unexpected error occurred during authentication',
63
+ instance: this.request.path
64
+ },
65
+ new Headers([['content-type', 'application/problem+json']])
66
+ );
47
67
  }
48
68
 
49
69
  if (this.request.path === '/ready') {
@@ -58,9 +78,9 @@ export abstract class ToolFunction extends Function {
58
78
  /**
59
79
  * Authenticate the incoming request by validating the OptiID token and organization ID
60
80
  *
61
- * @returns true if authentication succeeds
81
+ * @throws {ToolError} If authentication fails
62
82
  */
63
- private async authorizeRequest(): Promise<boolean> {
64
- return await authenticateRegularRequest(this.request);
83
+ private async authorizeRequest(): Promise<void> {
84
+ await authenticateRegularRequest(this.request);
65
85
  }
66
86
  }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './function/ToolFunction';
2
2
  export * from './function/GlobalToolFunction';
3
3
  export * from './types/Models';
4
+ export * from './types/ToolError';
4
5
  export * from './decorator/Decorator';
5
6
  export * from './auth/TokenVerifier';
6
7
  export { Tool, Interaction, InteractionResult } from './service/Service';
@@ -1,5 +1,6 @@
1
1
  import { toolsService, Tool, Interaction } from './Service';
2
2
  import { Parameter, ParameterType, AuthRequirement, OptiIdAuthDataCredentials, OptiIdAuthData } from '../types/Models';
3
+ import { ToolError } from '../types/ToolError';
3
4
  import { ToolFunction } from '../function/ToolFunction';
4
5
  import { logger } from '@zaiusinc/app-sdk';
5
6
 
@@ -370,7 +371,7 @@ describe('ToolsService', () => {
370
371
  );
371
372
  });
372
373
 
373
- it('should return 500 error when tool handler throws an error', async () => {
374
+ it('should return 500 error in RFC 9457 format when tool handler throws a regular error', async () => {
374
375
  const errorMessage = 'Tool execution failed';
375
376
  jest.mocked(mockTool.handler).mockRejectedValueOnce(new Error(errorMessage));
376
377
 
@@ -378,6 +379,13 @@ describe('ToolsService', () => {
378
379
  const response = await toolsService.processRequest(mockRequest, mockToolFunction);
379
380
 
380
381
  expect(response.status).toBe(500);
382
+ expect(response.bodyJSON).toEqual({
383
+ title: 'Internal Server Error',
384
+ status: 500,
385
+ detail: errorMessage,
386
+ instance: mockTool.endpoint
387
+ });
388
+ expect(response.headers.get('content-type')).toBe('application/problem+json');
381
389
  expect(logger.error).toHaveBeenCalledWith(
382
390
  `Error in function ${mockTool.name}:`,
383
391
  expect.any(Error)
@@ -391,6 +399,67 @@ describe('ToolsService', () => {
391
399
  const response = await toolsService.processRequest(mockRequest, mockToolFunction);
392
400
 
393
401
  expect(response.status).toBe(500);
402
+ expect(response.bodyJSON).toEqual({
403
+ title: 'Internal Server Error',
404
+ status: 500,
405
+ detail: 'An unexpected error occurred',
406
+ instance: mockTool.endpoint
407
+ });
408
+ expect(response.headers.get('content-type')).toBe('application/problem+json');
409
+ });
410
+
411
+ it('should return custom status code when tool handler throws ToolError', async () => {
412
+ const toolError = new ToolError('Resource not found', 404, 'The requested task does not exist');
413
+ jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
414
+
415
+ const mockRequest = createMockRequest();
416
+ const response = await toolsService.processRequest(mockRequest, mockToolFunction);
417
+
418
+ expect(response.status).toBe(404);
419
+ expect(response.bodyJSON).toEqual({
420
+ title: 'Resource not found',
421
+ status: 404,
422
+ detail: 'The requested task does not exist',
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(ToolError)
429
+ );
430
+ });
431
+
432
+ it('should return ToolError without detail field when detail is not provided', async () => {
433
+ const toolError = new ToolError('Bad request', 400);
434
+ jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
435
+
436
+ const mockRequest = createMockRequest();
437
+ const response = await toolsService.processRequest(mockRequest, mockToolFunction);
438
+
439
+ expect(response.status).toBe(400);
440
+ expect(response.bodyJSON).toEqual({
441
+ title: 'Bad request',
442
+ status: 400,
443
+ instance: mockTool.endpoint
444
+ });
445
+ expect(response.bodyJSON).not.toHaveProperty('detail');
446
+ expect(response.headers.get('content-type')).toBe('application/problem+json');
447
+ });
448
+
449
+ it('should default to 500 when ToolError is created without status', async () => {
450
+ const toolError = new ToolError('Database error');
451
+ jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
452
+
453
+ const mockRequest = createMockRequest();
454
+ const response = await toolsService.processRequest(mockRequest, mockToolFunction);
455
+
456
+ expect(response.status).toBe(500);
457
+ expect(response.bodyJSON).toEqual({
458
+ title: 'Database error',
459
+ status: 500,
460
+ instance: mockTool.endpoint
461
+ });
462
+ expect(response.headers.get('content-type')).toBe('application/problem+json');
394
463
  });
395
464
  });
396
465
 
@@ -488,7 +557,7 @@ describe('ToolsService', () => {
488
557
  );
489
558
  });
490
559
 
491
- it('should return 500 error when interaction handler throws an error', async () => {
560
+ it('should return 500 error in RFC 9457 format when interaction handler throws a regular error', async () => {
492
561
  const errorMessage = 'Interaction execution failed';
493
562
  jest.mocked(mockInteraction.handler).mockRejectedValueOnce(new Error(errorMessage));
494
563
 
@@ -500,11 +569,43 @@ describe('ToolsService', () => {
500
569
  const response = await toolsService.processRequest(interactionRequest, mockToolFunction);
501
570
 
502
571
  expect(response.status).toBe(500);
572
+ expect(response.bodyJSON).toEqual({
573
+ title: 'Internal Server Error',
574
+ status: 500,
575
+ detail: errorMessage,
576
+ instance: mockInteraction.endpoint
577
+ });
578
+ expect(response.headers.get('content-type')).toBe('application/problem+json');
503
579
  expect(logger.error).toHaveBeenCalledWith(
504
580
  `Error in function ${mockInteraction.name}:`,
505
581
  expect.any(Error)
506
582
  );
507
583
  });
584
+
585
+ it('should return custom status code when interaction handler throws ToolError', async () => {
586
+ const toolError = new ToolError('Webhook validation failed', 400, 'Invalid signature');
587
+ jest.mocked(mockInteraction.handler).mockRejectedValueOnce(toolError);
588
+
589
+ const interactionRequest = createMockRequest({
590
+ path: '/test-interaction',
591
+ bodyJSON: { data: { param1: 'test-value' } }
592
+ });
593
+
594
+ const response = await toolsService.processRequest(interactionRequest, mockToolFunction);
595
+
596
+ expect(response.status).toBe(400);
597
+ expect(response.bodyJSON).toEqual({
598
+ title: 'Webhook validation failed',
599
+ status: 400,
600
+ detail: 'Invalid signature',
601
+ instance: mockInteraction.endpoint
602
+ });
603
+ expect(response.headers.get('content-type')).toBe('application/problem+json');
604
+ expect(logger.error).toHaveBeenCalledWith(
605
+ `Error in function ${mockInteraction.name}:`,
606
+ expect.any(ToolError)
607
+ );
608
+ });
508
609
  });
509
610
 
510
611
  describe('error cases', () => {