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

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 (45) hide show
  1. package/README.md +42 -0
  2. package/dist/function/GlobalToolFunction.d.ts +1 -0
  3. package/dist/function/GlobalToolFunction.d.ts.map +1 -1
  4. package/dist/function/GlobalToolFunction.js +8 -0
  5. package/dist/function/GlobalToolFunction.js.map +1 -1
  6. package/dist/function/GlobalToolFunction.test.js +3 -0
  7. package/dist/function/GlobalToolFunction.test.js.map +1 -1
  8. package/dist/function/ToolFunction.d.ts +7 -1
  9. package/dist/function/ToolFunction.d.ts.map +1 -1
  10. package/dist/function/ToolFunction.js +14 -1
  11. package/dist/function/ToolFunction.js.map +1 -1
  12. package/dist/function/ToolFunction.test.js +3 -0
  13. package/dist/function/ToolFunction.test.js.map +1 -1
  14. package/dist/logging/ToolLogger.d.ts +34 -0
  15. package/dist/logging/ToolLogger.d.ts.map +1 -0
  16. package/dist/logging/ToolLogger.js +153 -0
  17. package/dist/logging/ToolLogger.js.map +1 -0
  18. package/dist/logging/ToolLogger.test.d.ts +2 -0
  19. package/dist/logging/ToolLogger.test.d.ts.map +1 -0
  20. package/dist/logging/ToolLogger.test.js +646 -0
  21. package/dist/logging/ToolLogger.test.js.map +1 -0
  22. package/dist/service/Service.d.ts.map +1 -1
  23. package/dist/service/Service.js +17 -0
  24. package/dist/service/Service.js.map +1 -1
  25. package/dist/service/Service.test.js +114 -6
  26. package/dist/service/Service.test.js.map +1 -1
  27. package/dist/validation/ParameterValidator.d.ts +42 -0
  28. package/dist/validation/ParameterValidator.d.ts.map +1 -0
  29. package/dist/validation/ParameterValidator.js +122 -0
  30. package/dist/validation/ParameterValidator.js.map +1 -0
  31. package/dist/validation/ParameterValidator.test.d.ts +2 -0
  32. package/dist/validation/ParameterValidator.test.d.ts.map +1 -0
  33. package/dist/validation/ParameterValidator.test.js +282 -0
  34. package/dist/validation/ParameterValidator.test.js.map +1 -0
  35. package/package.json +1 -1
  36. package/src/function/GlobalToolFunction.test.ts +3 -0
  37. package/src/function/GlobalToolFunction.ts +11 -0
  38. package/src/function/ToolFunction.test.ts +3 -0
  39. package/src/function/ToolFunction.ts +19 -1
  40. package/src/logging/ToolLogger.test.ts +753 -0
  41. package/src/logging/ToolLogger.ts +177 -0
  42. package/src/service/Service.test.ts +155 -14
  43. package/src/service/Service.ts +19 -1
  44. package/src/validation/ParameterValidator.test.ts +341 -0
  45. package/src/validation/ParameterValidator.ts +153 -0
@@ -0,0 +1,177 @@
1
+ import { logger, LogVisibility } from '@zaiusinc/app-sdk';
2
+ import * as App from '@zaiusinc/app-sdk';
3
+
4
+ /**
5
+ * Utility class for logging Opal tool requests and responses with security considerations
6
+ */
7
+ export class ToolLogger {
8
+ private static readonly SENSITIVE_FIELDS = [
9
+ // Authentication / secrets
10
+ 'password',
11
+ 'pass',
12
+ 'secret',
13
+ 'key',
14
+ 'token',
15
+ 'auth',
16
+ 'credentials',
17
+ 'access_token',
18
+ 'refresh_token',
19
+ 'api_key',
20
+ 'private_key',
21
+ 'client_secret',
22
+ 'session_token',
23
+ 'authorization',
24
+
25
+ // Payment-related
26
+ 'card_number',
27
+ 'credit_card',
28
+ 'cvv',
29
+ 'expiry_date',
30
+
31
+ // Personal info
32
+ 'ssn', // social security number
33
+ 'nid', // national ID
34
+ 'passport',
35
+ 'dob', // date of birth
36
+ 'email',
37
+ 'phone',
38
+ 'address',
39
+
40
+ // Misc / environment
41
+ 'otp',
42
+ 'pin',
43
+ 'security_answer',
44
+ 'security_question',
45
+ 'signing_key',
46
+ 'encryption_key',
47
+ 'jwt',
48
+ 'bearer_token'
49
+ ];
50
+
51
+ private static readonly MAX_PARAM_LENGTH = 100;
52
+ private static readonly MAX_ARRAY_ITEMS = 10;
53
+
54
+ /**
55
+ * Redacts sensitive data from an object
56
+ */
57
+ private static redactSensitiveData(data: any, maxDepth = 5): any {
58
+ if (maxDepth <= 0) {
59
+ return '[MAX_DEPTH_EXCEEDED]';
60
+ }
61
+
62
+ if (data === null || data === undefined) {
63
+ return data;
64
+ }
65
+
66
+ if (typeof data === 'string') {
67
+ return data.length > this.MAX_PARAM_LENGTH
68
+ ? `${data.substring(0, this.MAX_PARAM_LENGTH)}... (truncated, ${data.length} chars total)`
69
+ : data;
70
+ }
71
+
72
+ if (typeof data === 'number' || typeof data === 'boolean') {
73
+ return data;
74
+ }
75
+
76
+ if (Array.isArray(data)) {
77
+ const truncated = data.slice(0, this.MAX_ARRAY_ITEMS);
78
+ const result = truncated.map((item) => this.redactSensitiveData(item, maxDepth - 1));
79
+ if (data.length > this.MAX_ARRAY_ITEMS) {
80
+ result.push(`... (${data.length - this.MAX_ARRAY_ITEMS} more items truncated)`);
81
+ }
82
+ return result;
83
+ }
84
+
85
+ if (typeof data === 'object') {
86
+ const result: any = {};
87
+ for (const [key, value] of Object.entries(data)) {
88
+ // Check if this field contains sensitive data
89
+ const isSensitive = this.isSensitiveField(key);
90
+
91
+ if (isSensitive) {
92
+ result[key] = '[REDACTED]';
93
+ } else {
94
+ result[key] = this.redactSensitiveData(value, maxDepth - 1);
95
+ }
96
+ }
97
+ return result;
98
+ }
99
+
100
+ return data;
101
+ }
102
+
103
+ /**
104
+ * Checks if a field name is considered sensitive
105
+ */
106
+ private static isSensitiveField(fieldName: string): boolean {
107
+ const lowerKey = fieldName.toLowerCase();
108
+ return this.SENSITIVE_FIELDS.some((sensitiveField) =>
109
+ lowerKey.includes(sensitiveField)
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Creates a summary of request parameters
115
+ */
116
+ private static createParameterSummary(params: any): any {
117
+ if (!params) {
118
+ return null;
119
+ }
120
+
121
+ return this.redactSensitiveData(params);
122
+ }
123
+
124
+ /**
125
+ * Calculates content length of response data
126
+ */
127
+ private static calculateContentLength(response?: App.Response) {
128
+ if (!response) {
129
+ return 0;
130
+ }
131
+
132
+ try {
133
+ return response.bodyAsU8Array?.length || 'unknown';
134
+ } catch {
135
+ return 'unknown';
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Logs an incoming request
141
+ */
142
+ public static logRequest(
143
+ req: App.Request,
144
+ ): void {
145
+ const params = req.bodyJSON && req.bodyJSON.parameters ? req.bodyJSON.parameters : req.bodyJSON;
146
+ const requestLog = {
147
+ event: 'opal_tool_request',
148
+ path: req.path,
149
+ parameters: this.createParameterSummary(params)
150
+ };
151
+
152
+ // Log with Zaius audience so developers only see requests for accounts they have access to
153
+ logger.info(LogVisibility.Zaius, JSON.stringify(requestLog));
154
+ }
155
+
156
+ /**
157
+ * Logs a successful response
158
+ */
159
+ public static logResponse(
160
+ req: App.Request,
161
+ response: App.Response,
162
+ processingTimeMs?: number
163
+ ): void {
164
+ const responseLog = {
165
+ event: 'opal_tool_response',
166
+ path: req.path,
167
+ duration: processingTimeMs ? `${processingTimeMs}ms` : undefined,
168
+ status: response.status,
169
+ contentType: response.headers?.get('content-type') || 'unknown',
170
+ contentLength: this.calculateContentLength(response),
171
+ success: response.status >= 200 && response.status < 300
172
+ };
173
+
174
+ // Log with Zaius audience so developers only see requests for accounts they have access to
175
+ logger.info(LogVisibility.Zaius, JSON.stringify(responseLog));
176
+ }
177
+ }
@@ -11,11 +11,30 @@ jest.mock('@zaiusinc/app-sdk', () => ({
11
11
  Function: class {
12
12
  public constructor(public request: any) {}
13
13
  },
14
- Response: jest.fn().mockImplementation((status, data) => ({
14
+ Headers: class {
15
+ public constructor(headers: string[][] = []) {
16
+ this.headers = {};
17
+ headers.forEach(([name, value]) => {
18
+ this.headers[name.toLowerCase()] = value;
19
+ });
20
+ }
21
+ public headers: { [key: string]: string };
22
+ public set(name: string, value: string) {
23
+ this.headers[name.toLowerCase()] = value;
24
+ }
25
+ public get(name: string) {
26
+ return this.headers[name.toLowerCase()];
27
+ }
28
+ public has(name: string) {
29
+ return name.toLowerCase() in this.headers;
30
+ }
31
+ },
32
+ Response: jest.fn().mockImplementation((status, data, headers) => ({
15
33
  status,
16
34
  data,
17
35
  bodyJSON: data,
18
- bodyAsU8Array: new Uint8Array()
36
+ bodyAsU8Array: new Uint8Array(),
37
+ headers: headers || { get: jest.fn(), has: jest.fn(), set: jest.fn() }
19
38
  }))
20
39
  }));
21
40
 
@@ -522,15 +541,25 @@ describe('ToolsService', () => {
522
541
 
523
542
  describe('edge cases', () => {
524
543
  it('should handle request with null bodyJSON', async () => {
544
+ // Create a tool without required parameters
545
+ const toolWithoutRequiredParams = {
546
+ name: 'no_required_params_tool',
547
+ description: 'Tool without required parameters',
548
+ handler: jest.fn().mockResolvedValue({ result: 'success' }),
549
+ parameters: [], // No parameters defined
550
+ endpoint: '/no-required-params-tool'
551
+ };
552
+
525
553
  toolsService.registerTool(
526
- mockTool.name,
527
- mockTool.description,
528
- mockTool.handler,
529
- mockTool.parameters,
530
- mockTool.endpoint
554
+ toolWithoutRequiredParams.name,
555
+ toolWithoutRequiredParams.description,
556
+ toolWithoutRequiredParams.handler,
557
+ toolWithoutRequiredParams.parameters,
558
+ toolWithoutRequiredParams.endpoint
531
559
  );
532
560
 
533
561
  const requestWithNullBody = createMockRequest({
562
+ path: '/no-required-params-tool',
534
563
  bodyJSON: null,
535
564
  body: null
536
565
  });
@@ -538,19 +567,29 @@ describe('ToolsService', () => {
538
567
  const response = await toolsService.processRequest(requestWithNullBody, mockToolFunction);
539
568
 
540
569
  expect(response.status).toBe(200);
541
- expect(mockTool.handler).toHaveBeenCalledWith(mockToolFunction, null, undefined);
570
+ expect(toolWithoutRequiredParams.handler).toHaveBeenCalledWith(mockToolFunction, null, undefined);
542
571
  });
543
572
 
544
573
  it('should handle request with undefined bodyJSON', async () => {
574
+ // Create a tool without required parameters
575
+ const toolWithoutRequiredParams = {
576
+ name: 'no_required_params_tool_2',
577
+ description: 'Tool without required parameters',
578
+ handler: jest.fn().mockResolvedValue({ result: 'success' }),
579
+ parameters: [], // No parameters defined
580
+ endpoint: '/no-required-params-tool-2'
581
+ };
582
+
545
583
  toolsService.registerTool(
546
- mockTool.name,
547
- mockTool.description,
548
- mockTool.handler,
549
- mockTool.parameters,
550
- mockTool.endpoint
584
+ toolWithoutRequiredParams.name,
585
+ toolWithoutRequiredParams.description,
586
+ toolWithoutRequiredParams.handler,
587
+ toolWithoutRequiredParams.parameters,
588
+ toolWithoutRequiredParams.endpoint
551
589
  );
552
590
 
553
591
  const requestWithUndefinedBody = createMockRequest({
592
+ path: '/no-required-params-tool-2',
554
593
  bodyJSON: undefined,
555
594
  body: undefined
556
595
  });
@@ -558,7 +597,7 @@ describe('ToolsService', () => {
558
597
  const response = await toolsService.processRequest(requestWithUndefinedBody, mockToolFunction);
559
598
 
560
599
  expect(response.status).toBe(200);
561
- expect(mockTool.handler).toHaveBeenCalledWith(mockToolFunction, undefined, undefined);
600
+ expect(toolWithoutRequiredParams.handler).toHaveBeenCalledWith(mockToolFunction, undefined, undefined);
562
601
  });
563
602
 
564
603
  it('should extract auth data from bodyJSON when body exists', async () => {
@@ -657,5 +696,107 @@ describe('ToolsService', () => {
657
696
  );
658
697
  });
659
698
  });
699
+
700
+ describe('parameter validation', () => {
701
+ beforeEach(() => {
702
+ jest.clearAllMocks();
703
+ });
704
+
705
+ it('should validate parameters and return 400 for invalid types', async () => {
706
+ // Register a tool with specific parameter types
707
+ const toolWithTypedParams = {
708
+ name: 'typed_tool',
709
+ description: 'Tool with typed parameters',
710
+ handler: jest.fn().mockResolvedValue({ result: 'success' }),
711
+ parameters: [
712
+ new Parameter('name', ParameterType.String, 'User name', true),
713
+ new Parameter('age', ParameterType.Integer, 'User age', true),
714
+ new Parameter('active', ParameterType.Boolean, 'Is active', false)
715
+ ],
716
+ endpoint: '/typed-tool'
717
+ };
718
+
719
+ toolsService.registerTool(
720
+ toolWithTypedParams.name,
721
+ toolWithTypedParams.description,
722
+ toolWithTypedParams.handler,
723
+ toolWithTypedParams.parameters,
724
+ toolWithTypedParams.endpoint
725
+ );
726
+
727
+ // Send invalid parameter types
728
+ const invalidRequest = createMockRequest({
729
+ path: '/typed-tool',
730
+ bodyJSON: {
731
+ parameters: {
732
+ name: 123, // should be string
733
+ age: '25', // should be integer
734
+ active: 'true' // should be boolean
735
+ }
736
+ }
737
+ });
738
+
739
+ const response = await toolsService.processRequest(invalidRequest, mockToolFunction);
740
+
741
+ expect(response.status).toBe(400);
742
+
743
+ // Expect RFC 9457 Problem Details format
744
+ expect(response.bodyJSON).toHaveProperty('title', 'One or more validation errors occurred.');
745
+ expect(response.bodyJSON).toHaveProperty('status', 400);
746
+ expect(response.bodyJSON).toHaveProperty('detail', 'See \'errors\' field for details.');
747
+ expect(response.bodyJSON).toHaveProperty('instance', '/typed-tool');
748
+ expect(response.bodyJSON).toHaveProperty('errors');
749
+ expect(response.bodyJSON.errors).toHaveLength(3);
750
+
751
+ // Check error structure - field and message
752
+ const errors = response.bodyJSON.errors;
753
+ expect(errors[0]).toHaveProperty('field', 'name');
754
+ expect(errors[0]).toHaveProperty('message', "Parameter 'name' must be a string, but received number");
755
+
756
+ // Check that the content type is set to application/problem+json for RFC 9457 compliance
757
+ expect(response.headers).toBeDefined();
758
+ expect(response.headers.get('content-type')).toBe('application/problem+json');
759
+
760
+ // Verify the handler was not called
761
+ expect(toolWithTypedParams.handler).not.toHaveBeenCalled();
762
+ });
763
+
764
+ it('should skip validation for tools with no parameter definitions', async () => {
765
+ const toolWithoutParams = {
766
+ name: 'no_params_tool',
767
+ description: 'Tool without parameters',
768
+ handler: jest.fn().mockResolvedValue({ result: 'success' }),
769
+ parameters: [], // No parameters defined
770
+ endpoint: '/no-params-tool'
771
+ };
772
+
773
+ toolsService.registerTool(
774
+ toolWithoutParams.name,
775
+ toolWithoutParams.description,
776
+ toolWithoutParams.handler,
777
+ toolWithoutParams.parameters,
778
+ toolWithoutParams.endpoint
779
+ );
780
+
781
+ // Send request with any data (should be ignored)
782
+ const request = createMockRequest({
783
+ path: '/no-params-tool',
784
+ bodyJSON: {
785
+ parameters: {
786
+ unexpected: 'value'
787
+ }
788
+ }
789
+ });
790
+
791
+ const response = await toolsService.processRequest(request, mockToolFunction);
792
+
793
+ expect(response.status).toBe(200);
794
+ expect(toolWithoutParams.handler).toHaveBeenCalledWith(
795
+ mockToolFunction,
796
+ { unexpected: 'value' },
797
+ undefined
798
+ );
799
+ });
800
+ });
660
801
  });
661
802
  });
@@ -1,9 +1,10 @@
1
1
  /* eslint-disable max-classes-per-file */
2
2
  import { AuthRequirement, Parameter } from '../types/Models';
3
3
  import * as App from '@zaiusinc/app-sdk';
4
- import { logger } from '@zaiusinc/app-sdk';
4
+ import { logger, Headers } from '@zaiusinc/app-sdk';
5
5
  import { ToolFunction } from '../function/ToolFunction';
6
6
  import { GlobalToolFunction } from '../function/GlobalToolFunction';
7
+ import { ParameterValidator } from '../validation/ParameterValidator';
7
8
 
8
9
  /**
9
10
  * Default OptiID authentication requirement that will be enforced for all tools
@@ -173,6 +174,23 @@ export class ToolsService {
173
174
  params = req.bodyJSON;
174
175
  }
175
176
 
177
+ // Validate parameters before calling the handler (only if tool has parameter definitions)
178
+ if (func.parameters && func.parameters.length > 0) {
179
+ const validationResult = ParameterValidator.validate(params, func.parameters);
180
+ if (!validationResult.isValid) {
181
+ return new App.Response(400, {
182
+ title: 'One or more validation errors occurred.',
183
+ status: 400,
184
+ detail: "See 'errors' field for details.",
185
+ instance: func.endpoint,
186
+ errors: validationResult.errors.map((error) => ({
187
+ field: error.field,
188
+ message: error.message
189
+ }))
190
+ }, new Headers([['content-type', 'application/problem+json']]));
191
+ }
192
+ }
193
+
176
194
  // Extract auth data from body JSON
177
195
  const authData = req.bodyJSON ? req.bodyJSON.auth : undefined;
178
196