@optimizely-opal/opal-tool-ocp-sdk 1.0.0-OCP-1449.1 → 1.0.0-beta.10
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 +13 -3
- package/dist/function/GlobalToolFunction.d.ts +3 -2
- package/dist/function/GlobalToolFunction.d.ts.map +1 -1
- package/dist/function/GlobalToolFunction.js +7 -4
- package/dist/function/GlobalToolFunction.js.map +1 -1
- package/dist/function/GlobalToolFunction.test.js +16 -4
- package/dist/function/GlobalToolFunction.test.js.map +1 -1
- package/dist/function/ToolFunction.d.ts +3 -2
- package/dist/function/ToolFunction.d.ts.map +1 -1
- package/dist/function/ToolFunction.js +7 -4
- package/dist/function/ToolFunction.js.map +1 -1
- package/dist/function/ToolFunction.test.js +15 -3
- package/dist/function/ToolFunction.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/logging/ToolLogger.d.ts +11 -3
- package/dist/logging/ToolLogger.d.ts.map +1 -1
- package/dist/logging/ToolLogger.js +114 -13
- package/dist/logging/ToolLogger.js.map +1 -1
- package/dist/logging/ToolLogger.test.js +177 -71
- package/dist/logging/ToolLogger.test.js.map +1 -1
- package/dist/types/Models.d.ts +7 -1
- package/dist/types/Models.d.ts.map +1 -1
- package/dist/types/Models.js +5 -1
- package/dist/types/Models.js.map +1 -1
- package/dist/types/ToolError.d.ts +13 -0
- package/dist/types/ToolError.d.ts.map +1 -1
- package/dist/types/ToolError.js +30 -2
- package/dist/types/ToolError.js.map +1 -1
- package/dist/types/ToolError.test.js +27 -3
- package/dist/types/ToolError.test.js.map +1 -1
- package/dist/validation/ParameterValidator.test.js +2 -1
- package/dist/validation/ParameterValidator.test.js.map +1 -1
- package/package.json +3 -3
- package/src/function/GlobalToolFunction.test.ts +21 -6
- package/src/function/GlobalToolFunction.ts +9 -5
- package/src/function/ToolFunction.test.ts +21 -5
- package/src/function/ToolFunction.ts +9 -5
- package/src/index.ts +1 -1
- package/src/logging/ToolLogger.test.ts +225 -74
- package/src/logging/ToolLogger.ts +129 -15
- package/src/types/Models.ts +8 -1
- package/src/types/ToolError.test.ts +33 -3
- package/src/types/ToolError.ts +32 -2
- package/src/validation/ParameterValidator.test.ts +4 -1
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { logger, LogVisibility } from '@zaiusinc/app-sdk';
|
|
2
2
|
import * as App from '@zaiusinc/app-sdk';
|
|
3
3
|
|
|
4
|
+
const MAX_PARAM_LOG_LENGTH = 128;
|
|
5
|
+
const MAX_BODY_LOG_LENGTH = 256;
|
|
6
|
+
const MAX_ARRAY_ITEMS = 2;
|
|
7
|
+
|
|
4
8
|
/**
|
|
5
9
|
* Utility class for logging Opal tool requests and responses with security considerations
|
|
6
10
|
*/
|
|
@@ -48,13 +52,13 @@ export class ToolLogger {
|
|
|
48
52
|
'bearer_token'
|
|
49
53
|
];
|
|
50
54
|
|
|
51
|
-
private static readonly MAX_PARAM_LENGTH = 100;
|
|
52
|
-
private static readonly MAX_ARRAY_ITEMS = 10;
|
|
53
|
-
|
|
54
55
|
/**
|
|
55
56
|
* Redacts sensitive data from an object
|
|
56
57
|
*/
|
|
57
|
-
private static
|
|
58
|
+
private static redactSensitiveDataAndTruncate(data: any, maxDepth = 5, accumulatedLength = 0): any {
|
|
59
|
+
if (accumulatedLength > MAX_BODY_LOG_LENGTH) {
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
58
62
|
if (maxDepth <= 0) {
|
|
59
63
|
return '[MAX_DEPTH_EXCEEDED]';
|
|
60
64
|
}
|
|
@@ -64,9 +68,13 @@ export class ToolLogger {
|
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
if (typeof data === 'string') {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
if (data.length > MAX_PARAM_LOG_LENGTH) {
|
|
72
|
+
const lead = data.substring(0, MAX_PARAM_LOG_LENGTH - 10);
|
|
73
|
+
const tail = data.substring(data.length - 10);
|
|
74
|
+
return `${lead}...[${data.length - MAX_PARAM_LOG_LENGTH} truncated]...${tail}`;
|
|
75
|
+
} else {
|
|
76
|
+
return data;
|
|
77
|
+
}
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
if (typeof data === 'number' || typeof data === 'boolean') {
|
|
@@ -74,10 +82,10 @@ export class ToolLogger {
|
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
if (Array.isArray(data)) {
|
|
77
|
-
const truncated = data.slice(0,
|
|
78
|
-
const result = truncated.map((item) => this.
|
|
79
|
-
if (data.length >
|
|
80
|
-
result.push(`... (${data.length -
|
|
85
|
+
const truncated = data.slice(0, MAX_ARRAY_ITEMS);
|
|
86
|
+
const result = truncated.map((item) => this.redactSensitiveDataAndTruncate(item, maxDepth, accumulatedLength));
|
|
87
|
+
if (data.length > MAX_ARRAY_ITEMS) {
|
|
88
|
+
result.push(`... (${data.length - MAX_ARRAY_ITEMS} more items truncated)`);
|
|
81
89
|
}
|
|
82
90
|
return result;
|
|
83
91
|
}
|
|
@@ -85,13 +93,20 @@ export class ToolLogger {
|
|
|
85
93
|
if (typeof data === 'object') {
|
|
86
94
|
const result: any = {};
|
|
87
95
|
for (const [key, value] of Object.entries(data)) {
|
|
96
|
+
if (accumulatedLength > MAX_BODY_LOG_LENGTH) {
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
88
99
|
// Check if this field contains sensitive data
|
|
89
100
|
const isSensitive = this.isSensitiveField(key);
|
|
90
101
|
|
|
91
102
|
if (isSensitive) {
|
|
92
103
|
result[key] = '[REDACTED]';
|
|
93
104
|
} else {
|
|
94
|
-
result[key] = this.
|
|
105
|
+
result[key] = this.redactSensitiveDataAndTruncate(value, maxDepth - 1, accumulatedLength);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (result[key]) {
|
|
109
|
+
accumulatedLength += JSON.stringify(result[key]).length;
|
|
95
110
|
}
|
|
96
111
|
}
|
|
97
112
|
return result;
|
|
@@ -118,7 +133,7 @@ export class ToolLogger {
|
|
|
118
133
|
return null;
|
|
119
134
|
}
|
|
120
135
|
|
|
121
|
-
return this.
|
|
136
|
+
return this.redactSensitiveDataAndTruncate(params);
|
|
122
137
|
}
|
|
123
138
|
|
|
124
139
|
/**
|
|
@@ -136,6 +151,99 @@ export class ToolLogger {
|
|
|
136
151
|
}
|
|
137
152
|
}
|
|
138
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Extracts the response body as a string or parsed JSON object
|
|
156
|
+
*/
|
|
157
|
+
private static getResponseBody(response?: App.Response): any {
|
|
158
|
+
if (!response) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const contentType = response.headers?.get('content-type') || '';
|
|
164
|
+
const isJson = contentType.includes('application/json') || contentType.includes('application/problem+json');
|
|
165
|
+
const isText = contentType.startsWith('text/');
|
|
166
|
+
|
|
167
|
+
if (!isJson && !isText) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Try to access bodyAsU8Array - this may throw
|
|
172
|
+
const bodyData = response.bodyAsU8Array;
|
|
173
|
+
if (!bodyData) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Convert Uint8Array to string
|
|
178
|
+
const bodyString = Buffer.from(bodyData).toString();
|
|
179
|
+
if (!bodyString) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Try to parse as JSON if content-type indicates JSON
|
|
184
|
+
if (isJson) {
|
|
185
|
+
try {
|
|
186
|
+
return JSON.parse(bodyString);
|
|
187
|
+
} catch {
|
|
188
|
+
// If JSON parsing fails, return as string
|
|
189
|
+
return bodyString;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Return as plain text for non-JSON content types
|
|
194
|
+
return bodyString;
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Creates a summary of response body with security redaction and truncation
|
|
202
|
+
* For failed responses (4xx, 5xx): returns full body with redacted sensitive data
|
|
203
|
+
* For successful responses (2xx): returns first 100 chars with redacted sensitive data
|
|
204
|
+
*/
|
|
205
|
+
private static createResponseBodySummary(response?: App.Response, success?: boolean): any {
|
|
206
|
+
const body = this.getResponseBody(response);
|
|
207
|
+
if (body === null || body === undefined) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// For objects (parsed JSON), apply redaction
|
|
212
|
+
if (typeof body === 'object') {
|
|
213
|
+
// For failed responses, don't truncate strings within the object
|
|
214
|
+
const redactedBody = this.redactSensitiveDataAndTruncate(body, 5);
|
|
215
|
+
|
|
216
|
+
// For successful responses, truncate to first MAX_BODY_LOG_LENGTH chars
|
|
217
|
+
if (success) {
|
|
218
|
+
const bodyString = JSON.stringify(redactedBody);
|
|
219
|
+
if (bodyString.length > MAX_BODY_LOG_LENGTH) {
|
|
220
|
+
const truncated = bodyString.substring(0, MAX_BODY_LOG_LENGTH);
|
|
221
|
+
return `${truncated}... (truncated)`;
|
|
222
|
+
}
|
|
223
|
+
return redactedBody;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// For failed responses, return full redacted body
|
|
227
|
+
return redactedBody;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// For strings (plain text or unparseable JSON)
|
|
231
|
+
if (typeof body === 'string') {
|
|
232
|
+
// For successful responses, truncate to first 100 chars
|
|
233
|
+
if (success) {
|
|
234
|
+
if (body.length > MAX_BODY_LOG_LENGTH) {
|
|
235
|
+
return `${body.substring(0, MAX_BODY_LOG_LENGTH)}... (truncated)`;
|
|
236
|
+
}
|
|
237
|
+
return body;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// For failed responses, return full body
|
|
241
|
+
return body;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return body;
|
|
245
|
+
}
|
|
246
|
+
|
|
139
247
|
/**
|
|
140
248
|
* Logs an incoming request
|
|
141
249
|
*/
|
|
@@ -162,16 +270,22 @@ export class ToolLogger {
|
|
|
162
270
|
response: App.Response,
|
|
163
271
|
processingTimeMs?: number
|
|
164
272
|
): void {
|
|
165
|
-
const
|
|
273
|
+
const success = response.status >= 200 && response.status < 300;
|
|
274
|
+
const responseLog: any = {
|
|
166
275
|
event: 'opal_tool_response',
|
|
167
276
|
path: req.path,
|
|
168
277
|
duration: processingTimeMs ? `${processingTimeMs}ms` : undefined,
|
|
169
278
|
status: response.status,
|
|
170
279
|
contentType: response.headers?.get('content-type') || 'unknown',
|
|
171
280
|
contentLength: this.calculateContentLength(response),
|
|
172
|
-
success
|
|
281
|
+
success
|
|
173
282
|
};
|
|
174
283
|
|
|
284
|
+
const responseBodySummary = this.createResponseBodySummary(response, success);
|
|
285
|
+
if (responseBodySummary) {
|
|
286
|
+
responseLog.responseBody = responseBodySummary;
|
|
287
|
+
}
|
|
288
|
+
|
|
175
289
|
// Log with Zaius audience so developers only see requests for accounts they have access to
|
|
176
290
|
logger.info(LogVisibility.Zaius, JSON.stringify(responseLog));
|
|
177
291
|
}
|
package/src/types/Models.ts
CHANGED
|
@@ -143,7 +143,9 @@ export class IslandConfig {
|
|
|
143
143
|
|
|
144
144
|
public constructor(
|
|
145
145
|
public fields: IslandField[],
|
|
146
|
-
public actions: IslandAction[]
|
|
146
|
+
public actions: IslandAction[],
|
|
147
|
+
public type?: 'card' | 'button',
|
|
148
|
+
public icon?: string,
|
|
147
149
|
) {}
|
|
148
150
|
}
|
|
149
151
|
|
|
@@ -161,3 +163,8 @@ export class IslandResponse {
|
|
|
161
163
|
}
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
export interface ReadyResponse {
|
|
167
|
+
ready: boolean;
|
|
168
|
+
reason?: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
@@ -5,7 +5,8 @@ describe('ToolError', () => {
|
|
|
5
5
|
it('should create error with message and default status 500', () => {
|
|
6
6
|
const error = new ToolError('Something went wrong');
|
|
7
7
|
|
|
8
|
-
expect(error.message).toBe('Something went wrong');
|
|
8
|
+
expect(error.message).toBe('[500] Something went wrong');
|
|
9
|
+
expect(error.title).toBe('Something went wrong');
|
|
9
10
|
expect(error.status).toBe(500);
|
|
10
11
|
expect(error.detail).toBeUndefined();
|
|
11
12
|
expect(error.name).toBe('ToolError');
|
|
@@ -14,7 +15,8 @@ describe('ToolError', () => {
|
|
|
14
15
|
it('should create error with custom status code', () => {
|
|
15
16
|
const error = new ToolError('Not found', 404);
|
|
16
17
|
|
|
17
|
-
expect(error.message).toBe('Not found');
|
|
18
|
+
expect(error.message).toBe('[404] Not found');
|
|
19
|
+
expect(error.title).toBe('Not found');
|
|
18
20
|
expect(error.status).toBe(404);
|
|
19
21
|
expect(error.detail).toBeUndefined();
|
|
20
22
|
});
|
|
@@ -22,11 +24,39 @@ describe('ToolError', () => {
|
|
|
22
24
|
it('should create error with message, status, and detail', () => {
|
|
23
25
|
const error = new ToolError('Validation failed', 400, 'Email format is invalid');
|
|
24
26
|
|
|
25
|
-
expect(error.message).toBe('Validation failed');
|
|
27
|
+
expect(error.message).toBe('[400] Validation failed: Email format is invalid');
|
|
28
|
+
expect(error.title).toBe('Validation failed');
|
|
26
29
|
expect(error.status).toBe(400);
|
|
27
30
|
expect(error.detail).toBe('Email format is invalid');
|
|
28
31
|
});
|
|
29
32
|
|
|
33
|
+
it('should create error with errors array in message', () => {
|
|
34
|
+
const errors = [
|
|
35
|
+
{ field: 'email', message: 'Invalid email format' },
|
|
36
|
+
{ field: 'age', message: 'Must be a positive number' }
|
|
37
|
+
];
|
|
38
|
+
const error = new ToolError('Validation failed', 400, "See 'errors' field for details.", errors);
|
|
39
|
+
|
|
40
|
+
expect(error.message).toBe(
|
|
41
|
+
'[400] Validation failed: email (Invalid email format); age (Must be a positive number)'
|
|
42
|
+
);
|
|
43
|
+
expect(error.title).toBe('Validation failed');
|
|
44
|
+
expect(error.status).toBe(400);
|
|
45
|
+
expect(error.detail).toBe("See 'errors' field for details.");
|
|
46
|
+
expect(error.errors).toEqual(errors);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should create error with single error in message', () => {
|
|
50
|
+
const errors = [{ field: 'username', message: 'Required field is missing' }];
|
|
51
|
+
const error = new ToolError('Validation failed', 400, undefined, errors);
|
|
52
|
+
|
|
53
|
+
expect(error.message).toBe('[400] Validation failed: username (Required field is missing)');
|
|
54
|
+
expect(error.title).toBe('Validation failed');
|
|
55
|
+
expect(error.status).toBe(400);
|
|
56
|
+
expect(error.detail).toBeUndefined();
|
|
57
|
+
expect(error.errors).toEqual(errors);
|
|
58
|
+
});
|
|
59
|
+
|
|
30
60
|
it('should be an instance of Error', () => {
|
|
31
61
|
const error = new ToolError('Test error');
|
|
32
62
|
|
package/src/types/ToolError.ts
CHANGED
|
@@ -9,18 +9,27 @@ export interface ValidationError {
|
|
|
9
9
|
/**
|
|
10
10
|
* Custom error class for tool functions that supports RFC 9457 Problem Details format
|
|
11
11
|
*
|
|
12
|
+
* The error message includes status code and details for better logging:
|
|
13
|
+
* - No detail: "[status] message"
|
|
14
|
+
* - With detail: "[status] message: detail"
|
|
15
|
+
* - With errors: "[status] message: field1 (error1); field2 (error2)"
|
|
16
|
+
*
|
|
12
17
|
* @example
|
|
13
18
|
* ```typescript
|
|
14
19
|
* // Throw a 404 error
|
|
20
|
+
* // Message: "[404] Resource not found: The requested task does not exist"
|
|
15
21
|
* throw new ToolError('Resource not found', 404, 'The requested task does not exist');
|
|
16
22
|
*
|
|
17
23
|
* // Throw a 400 error
|
|
24
|
+
* // Message: "[400] Invalid input: The priority must be "low", "medium", or "high""
|
|
18
25
|
* throw new ToolError('Invalid input', 400, 'The priority must be "low", "medium", or "high"');
|
|
19
26
|
*
|
|
20
27
|
* // Throw a 500 error (default)
|
|
28
|
+
* // Message: "[500] Database connection failed"
|
|
21
29
|
* throw new ToolError('Database connection failed');
|
|
22
30
|
*
|
|
23
31
|
* // Throw a validation error with multiple field errors
|
|
32
|
+
* // Message: "[400] Validation failed: email (Invalid email format); age (Must be a positive number)"
|
|
24
33
|
* throw new ToolError('Validation failed', 400, "See 'errors' field for details.", [
|
|
25
34
|
* { field: 'email', message: 'Invalid email format' },
|
|
26
35
|
* { field: 'age', message: 'Must be a positive number' }
|
|
@@ -43,6 +52,11 @@ export class ToolError extends Error {
|
|
|
43
52
|
*/
|
|
44
53
|
public readonly errors?: ValidationError[];
|
|
45
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The title field for RFC 9457 format (same as message when no detail is provided)
|
|
57
|
+
*/
|
|
58
|
+
public readonly title: string;
|
|
59
|
+
|
|
46
60
|
/**
|
|
47
61
|
* Create a new ToolError
|
|
48
62
|
*
|
|
@@ -57,8 +71,24 @@ export class ToolError extends Error {
|
|
|
57
71
|
detail?: string,
|
|
58
72
|
errors?: ValidationError[]
|
|
59
73
|
) {
|
|
60
|
-
|
|
74
|
+
// Build comprehensive error message for logging/debugging
|
|
75
|
+
// Format: [status] message: details
|
|
76
|
+
let fullMessage = `[${status}] ${message}`;
|
|
77
|
+
|
|
78
|
+
if (errors && errors.length > 0) {
|
|
79
|
+
// Errors take precedence: field1 (message1); field2 (message2)
|
|
80
|
+
const errorDetails = errors
|
|
81
|
+
.map((err) => `${err.field} (${err.message})`)
|
|
82
|
+
.join('; ');
|
|
83
|
+
fullMessage += `: ${errorDetails}`;
|
|
84
|
+
} else if (detail) {
|
|
85
|
+
// Include detail if no errors
|
|
86
|
+
fullMessage += `: ${detail}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
super(fullMessage);
|
|
61
90
|
this.name = 'ToolError';
|
|
91
|
+
this.title = message;
|
|
62
92
|
this.status = status;
|
|
63
93
|
this.detail = detail;
|
|
64
94
|
this.errors = errors;
|
|
@@ -77,7 +107,7 @@ export class ToolError extends Error {
|
|
|
77
107
|
*/
|
|
78
108
|
public toProblemDetails(instance: string): Record<string, unknown> {
|
|
79
109
|
const problemDetails: Record<string, unknown> = {
|
|
80
|
-
title: this.
|
|
110
|
+
title: this.title,
|
|
81
111
|
status: this.status,
|
|
82
112
|
instance
|
|
83
113
|
};
|
|
@@ -49,7 +49,10 @@ describe('ParameterValidator', () => {
|
|
|
49
49
|
expect(error).toBeInstanceOf(ToolError);
|
|
50
50
|
const toolError = error as ToolError;
|
|
51
51
|
expect(toolError.status).toBe(400);
|
|
52
|
-
expect(toolError.message).toBe(
|
|
52
|
+
expect(toolError.message).toBe(
|
|
53
|
+
"[400] One or more validation errors occurred.: email (Required parameter 'email' is missing)"
|
|
54
|
+
);
|
|
55
|
+
expect(toolError.title).toBe('One or more validation errors occurred.');
|
|
53
56
|
expect(toolError.detail).toBe("See 'errors' field for details.");
|
|
54
57
|
expect(toolError.errors).toHaveLength(1);
|
|
55
58
|
expect(toolError.errors).toEqual([{
|