@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.
Files changed (47) hide show
  1. package/README.md +13 -3
  2. package/dist/function/GlobalToolFunction.d.ts +3 -2
  3. package/dist/function/GlobalToolFunction.d.ts.map +1 -1
  4. package/dist/function/GlobalToolFunction.js +7 -4
  5. package/dist/function/GlobalToolFunction.js.map +1 -1
  6. package/dist/function/GlobalToolFunction.test.js +16 -4
  7. package/dist/function/GlobalToolFunction.test.js.map +1 -1
  8. package/dist/function/ToolFunction.d.ts +3 -2
  9. package/dist/function/ToolFunction.d.ts.map +1 -1
  10. package/dist/function/ToolFunction.js +7 -4
  11. package/dist/function/ToolFunction.js.map +1 -1
  12. package/dist/function/ToolFunction.test.js +15 -3
  13. package/dist/function/ToolFunction.test.js.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/logging/ToolLogger.d.ts +11 -3
  19. package/dist/logging/ToolLogger.d.ts.map +1 -1
  20. package/dist/logging/ToolLogger.js +114 -13
  21. package/dist/logging/ToolLogger.js.map +1 -1
  22. package/dist/logging/ToolLogger.test.js +177 -71
  23. package/dist/logging/ToolLogger.test.js.map +1 -1
  24. package/dist/types/Models.d.ts +7 -1
  25. package/dist/types/Models.d.ts.map +1 -1
  26. package/dist/types/Models.js +5 -1
  27. package/dist/types/Models.js.map +1 -1
  28. package/dist/types/ToolError.d.ts +13 -0
  29. package/dist/types/ToolError.d.ts.map +1 -1
  30. package/dist/types/ToolError.js +30 -2
  31. package/dist/types/ToolError.js.map +1 -1
  32. package/dist/types/ToolError.test.js +27 -3
  33. package/dist/types/ToolError.test.js.map +1 -1
  34. package/dist/validation/ParameterValidator.test.js +2 -1
  35. package/dist/validation/ParameterValidator.test.js.map +1 -1
  36. package/package.json +3 -3
  37. package/src/function/GlobalToolFunction.test.ts +21 -6
  38. package/src/function/GlobalToolFunction.ts +9 -5
  39. package/src/function/ToolFunction.test.ts +21 -5
  40. package/src/function/ToolFunction.ts +9 -5
  41. package/src/index.ts +1 -1
  42. package/src/logging/ToolLogger.test.ts +225 -74
  43. package/src/logging/ToolLogger.ts +129 -15
  44. package/src/types/Models.ts +8 -1
  45. package/src/types/ToolError.test.ts +33 -3
  46. package/src/types/ToolError.ts +32 -2
  47. 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 redactSensitiveData(data: any, maxDepth = 5): any {
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
- return data.length > this.MAX_PARAM_LENGTH
68
- ? `${data.substring(0, this.MAX_PARAM_LENGTH)}... (truncated, ${data.length} chars total)`
69
- : data;
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, this.MAX_ARRAY_ITEMS);
78
- const result = truncated.map((item) => this.redactSensitiveData(item, maxDepth));
79
- if (data.length > this.MAX_ARRAY_ITEMS) {
80
- result.push(`... (${data.length - this.MAX_ARRAY_ITEMS} more items truncated)`);
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.redactSensitiveData(value, maxDepth - 1);
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.redactSensitiveData(params);
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 responseLog = {
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: response.status >= 200 && response.status < 300
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
  }
@@ -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
 
@@ -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
- super(message);
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.message,
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('One or more validation errors occurred.');
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([{