@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.
- package/README.md +42 -0
- package/dist/function/GlobalToolFunction.d.ts +1 -0
- package/dist/function/GlobalToolFunction.d.ts.map +1 -1
- package/dist/function/GlobalToolFunction.js +8 -0
- package/dist/function/GlobalToolFunction.js.map +1 -1
- package/dist/function/GlobalToolFunction.test.js +3 -0
- package/dist/function/GlobalToolFunction.test.js.map +1 -1
- package/dist/function/ToolFunction.d.ts +7 -1
- package/dist/function/ToolFunction.d.ts.map +1 -1
- package/dist/function/ToolFunction.js +14 -1
- package/dist/function/ToolFunction.js.map +1 -1
- package/dist/function/ToolFunction.test.js +3 -0
- package/dist/function/ToolFunction.test.js.map +1 -1
- package/dist/logging/ToolLogger.d.ts +34 -0
- package/dist/logging/ToolLogger.d.ts.map +1 -0
- package/dist/logging/ToolLogger.js +153 -0
- package/dist/logging/ToolLogger.js.map +1 -0
- package/dist/logging/ToolLogger.test.d.ts +2 -0
- package/dist/logging/ToolLogger.test.d.ts.map +1 -0
- package/dist/logging/ToolLogger.test.js +646 -0
- package/dist/logging/ToolLogger.test.js.map +1 -0
- package/dist/service/Service.d.ts.map +1 -1
- package/dist/service/Service.js +17 -0
- package/dist/service/Service.js.map +1 -1
- package/dist/service/Service.test.js +114 -6
- package/dist/service/Service.test.js.map +1 -1
- package/dist/validation/ParameterValidator.d.ts +42 -0
- package/dist/validation/ParameterValidator.d.ts.map +1 -0
- package/dist/validation/ParameterValidator.js +122 -0
- package/dist/validation/ParameterValidator.js.map +1 -0
- package/dist/validation/ParameterValidator.test.d.ts +2 -0
- package/dist/validation/ParameterValidator.test.d.ts.map +1 -0
- package/dist/validation/ParameterValidator.test.js +282 -0
- package/dist/validation/ParameterValidator.test.js.map +1 -0
- package/package.json +1 -1
- package/src/function/GlobalToolFunction.test.ts +3 -0
- package/src/function/GlobalToolFunction.ts +11 -0
- package/src/function/ToolFunction.test.ts +3 -0
- package/src/function/ToolFunction.ts +19 -1
- package/src/logging/ToolLogger.test.ts +753 -0
- package/src/logging/ToolLogger.ts +177 -0
- package/src/service/Service.test.ts +155 -14
- package/src/service/Service.ts +19 -1
- package/src/validation/ParameterValidator.test.ts +341 -0
- 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
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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(
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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(
|
|
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
|
});
|
package/src/service/Service.ts
CHANGED
|
@@ -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
|
|