@vertesia/workflow 1.4.0 → 1.5.0-dev.20260713.151751Z

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 (72) hide show
  1. package/lib/activities/detectDocumentLanguage.d.ts +28 -0
  2. package/lib/activities/detectDocumentLanguage.d.ts.map +1 -0
  3. package/lib/activities/detectDocumentLanguage.js +58 -0
  4. package/lib/activities/detectDocumentLanguage.js.map +1 -0
  5. package/lib/activities/extractDocumentText.js +12 -1
  6. package/lib/activities/extractDocumentText.js.map +1 -1
  7. package/lib/activities/generateEmbeddings.d.ts +4 -0
  8. package/lib/activities/generateEmbeddings.d.ts.map +1 -1
  9. package/lib/activities/generateEmbeddings.js +43 -39
  10. package/lib/activities/generateEmbeddings.js.map +1 -1
  11. package/lib/activities/generateOrAssignContentType.d.ts +10 -0
  12. package/lib/activities/generateOrAssignContentType.d.ts.map +1 -1
  13. package/lib/activities/generateOrAssignContentType.js +40 -3
  14. package/lib/activities/generateOrAssignContentType.js.map +1 -1
  15. package/lib/activities/handleError.d.ts.map +1 -1
  16. package/lib/activities/handleError.js +1 -0
  17. package/lib/activities/handleError.js.map +1 -1
  18. package/lib/activities/index-dsl.d.ts +1 -0
  19. package/lib/activities/index-dsl.d.ts.map +1 -1
  20. package/lib/activities/index-dsl.js +1 -0
  21. package/lib/activities/index-dsl.js.map +1 -1
  22. package/lib/activities/media/prepareAudio.d.ts.map +1 -1
  23. package/lib/activities/media/prepareAudio.js +4 -1
  24. package/lib/activities/media/prepareAudio.js.map +1 -1
  25. package/lib/activities/media/prepareVideo.d.ts.map +1 -1
  26. package/lib/activities/media/prepareVideo.js +4 -1
  27. package/lib/activities/media/prepareVideo.js.map +1 -1
  28. package/lib/activities/notifyWebhook.d.ts +2 -0
  29. package/lib/activities/notifyWebhook.d.ts.map +1 -1
  30. package/lib/activities/notifyWebhook.js +82 -12
  31. package/lib/activities/notifyWebhook.js.map +1 -1
  32. package/lib/bulk-import.d.ts +1 -0
  33. package/lib/bulk-import.d.ts.map +1 -1
  34. package/lib/conversion/TextractProcessor.js +4 -4
  35. package/lib/conversion/TextractProcessor.js.map +1 -1
  36. package/lib/conversion/image.d.ts.map +1 -1
  37. package/lib/conversion/image.js +2 -0
  38. package/lib/conversion/image.js.map +1 -1
  39. package/lib/dsl/setup/fetch/DataProvider.js +3 -3
  40. package/lib/dsl/setup/fetch/DataProvider.js.map +1 -1
  41. package/lib/index.d.ts +1 -0
  42. package/lib/index.d.ts.map +1 -1
  43. package/lib/index.js +1 -0
  44. package/lib/index.js.map +1 -1
  45. package/lib/system/notifyWebhookWorkflow.d.ts +10 -3
  46. package/lib/system/notifyWebhookWorkflow.d.ts.map +1 -1
  47. package/lib/system/notifyWebhookWorkflow.js +19 -1
  48. package/lib/system/notifyWebhookWorkflow.js.map +1 -1
  49. package/lib/system/recalculateEmbeddingsWorkflow.d.ts +4 -0
  50. package/lib/system/recalculateEmbeddingsWorkflow.d.ts.map +1 -1
  51. package/lib/workflows-bundle.js +1925 -935
  52. package/package.json +17 -17
  53. package/src/activities/detectDocumentLanguage.ts +92 -0
  54. package/src/activities/extractDocumentText.ts +13 -1
  55. package/src/activities/generateEmbeddings.ts +44 -42
  56. package/src/activities/generateOrAssignContentType.ts +56 -3
  57. package/src/activities/handleError.ts +1 -0
  58. package/src/activities/index-dsl.ts +1 -0
  59. package/src/activities/media/prepareAudio.ts +4 -1
  60. package/src/activities/media/prepareVideo.ts +4 -1
  61. package/src/activities/media/probeMediaStreams.test.ts +1 -1
  62. package/src/activities/media/saveGladiaTranscription.test.ts +2 -1
  63. package/src/activities/media/transcribeMediaWithGladia.test.ts +1 -1
  64. package/src/activities/notifyWebhook.test.ts +119 -0
  65. package/src/activities/notifyWebhook.ts +86 -12
  66. package/src/bulk-import.ts +1 -0
  67. package/src/conversion/TextractProcessor.ts +4 -4
  68. package/src/conversion/image.test.ts +24 -0
  69. package/src/conversion/image.ts +3 -0
  70. package/src/dsl/setup/fetch/DataProvider.ts +3 -3
  71. package/src/index.ts +1 -0
  72. package/src/system/notifyWebhookWorkflow.ts +31 -4
@@ -1,4 +1,4 @@
1
- import { log } from '@temporalio/activity';
1
+ import { ApplicationFailure, log } from '@temporalio/activity';
2
2
  import { VertesiaClient } from '@vertesia/client';
3
3
  import {
4
4
  ApiVersions,
@@ -9,6 +9,7 @@ import {
9
9
  } from '@vertesia/common';
10
10
  import { setupActivity } from '../dsl/setup/ActivityContext.js';
11
11
  import { WorkflowParamNotFoundError } from '../errors.js';
12
+ import { safeFetch, URLValidationError } from '../security/ssrf.js';
12
13
  import { getVertesiaClientOptions } from '../utils/client.js';
13
14
 
14
15
  export interface NotifyWebhookParams {
@@ -18,9 +19,11 @@ export interface NotifyWebhookParams {
18
19
  workflow_run_id: string; //The ID of the specific workflow run sending the notification
19
20
  event_name: string; //The event that triggered the notification (e.g. "completed", "failed", etc.)
20
21
  detail?: Record<string, unknown>; // additional data about the event if any. It will be send to the webhook when using POST
22
+ body?: string; // raw request body. When set, it bypasses workflow notification payload formatting.
21
23
  //target_url: string; //URL to send the notification to
22
24
  method: 'GET' | 'POST'; //HTTP method to use
23
25
  headers?: Record<string, string>; // additional headers to send
26
+ timeout_ms?: number;
24
27
  }
25
28
 
26
29
  export interface WebhookNotificationPayload {
@@ -41,10 +44,28 @@ export interface NotifyWebhookResult {
41
44
  url: string;
42
45
  }
43
46
 
47
+ function validationStatusCode(error: unknown): number | undefined {
48
+ if (typeof error !== 'object' || error === null) {
49
+ return undefined;
50
+ }
51
+ const record = error as { statusCode?: unknown; status?: unknown; code?: unknown };
52
+ const status = record.statusCode ?? record.status ?? record.code;
53
+ return typeof status === 'number' ? status : undefined;
54
+ }
55
+
56
+ function isNonRetryableUrlValidationError(error: unknown): boolean {
57
+ const status = validationStatusCode(error);
58
+ if (status !== undefined) {
59
+ return status >= 400 && status < 500;
60
+ }
61
+ const message = error instanceof Error ? error.message : String(error);
62
+ return /blocked|not allowed|forbidden|invalid url|internal hosts|private network|metadata/i.test(message);
63
+ }
64
+
44
65
  export async function notifyWebhook(
45
66
  payload: DSLActivityExecutionPayload<NotifyWebhookParams>,
46
67
  ): Promise<NotifyWebhookResult> {
47
- const { params } = await setupActivity<NotifyWebhookParams>(payload);
68
+ const { params, client } = await setupActivity<NotifyWebhookParams>(payload);
48
69
  const { webhook, method, headers: defaultHeaders } = params;
49
70
  // resolve the url and the api version of the webhook
50
71
  let target_url: string, version: number | undefined;
@@ -57,25 +78,73 @@ export async function notifyWebhook(
57
78
 
58
79
  if (!target_url) throw new WorkflowParamNotFoundError('target_url');
59
80
 
81
+ try {
82
+ await client.apps.validateUrl(target_url);
83
+ } catch (err: unknown) {
84
+ const message = err instanceof Error ? err.message : String(err);
85
+ if (!isNonRetryableUrlValidationError(err)) {
86
+ log.warn('URL validation preflight failed; retrying webhook notification', {
87
+ url: target_url,
88
+ workflow_id: params.workflow_id,
89
+ workflow_run_id: params.workflow_run_id,
90
+ error: message,
91
+ });
92
+ throw err;
93
+ }
94
+ log.warn('URL validation blocked webhook endpoint', {
95
+ url: target_url,
96
+ workflow_id: params.workflow_id,
97
+ workflow_run_id: params.workflow_run_id,
98
+ error: message,
99
+ });
100
+ throw ApplicationFailure.create({
101
+ message: `Webhook endpoint blocked: ${message}`,
102
+ nonRetryable: true,
103
+ });
104
+ }
105
+
60
106
  const hasBody = method === 'POST'; //body is sent only for POST, always includes workflow info
61
107
 
62
108
  const headers = {
63
109
  ...defaultHeaders,
64
110
  };
65
- if (hasBody) {
111
+ if (hasBody && !hasHeader(headers, 'content-type')) {
66
112
  headers['Content-Type'] = 'application/json';
67
113
  }
68
- const body = hasBody ? await createRequestBody(payload, params, version) : undefined;
114
+ const body = params.body ?? (hasBody ? await createRequestBody(payload, params, version) : undefined);
69
115
 
70
116
  log.info(`Notifying webhook at ${target_url}`);
71
- const res = await fetch(target_url, {
72
- method,
73
- body,
74
- headers,
75
- }).catch((err) => {
76
- log.error(`An error occurred while notifying webhook at ${target_url}`, { err });
77
- throw err;
78
- });
117
+ const timeoutMs = typeof params.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : undefined;
118
+ const controller = timeoutMs ? new AbortController() : undefined;
119
+ const timeout = timeoutMs ? setTimeout(() => controller?.abort(), timeoutMs) : undefined;
120
+ let res: Response;
121
+ try {
122
+ res = await safeFetch(target_url, {
123
+ method,
124
+ body,
125
+ headers,
126
+ ...(controller ? { signal: controller.signal } : {}),
127
+ }).catch((err: unknown) => {
128
+ if (err instanceof URLValidationError) {
129
+ log.warn('Redirect blocked on webhook endpoint', {
130
+ url: target_url,
131
+ workflow_id: params.workflow_id,
132
+ workflow_run_id: params.workflow_run_id,
133
+ error: err.message,
134
+ });
135
+ throw ApplicationFailure.create({
136
+ message: `Webhook endpoint blocked: ${err.message}`,
137
+ nonRetryable: true,
138
+ });
139
+ }
140
+ log.error(`An error occurred while notifying webhook at ${target_url}`, { err });
141
+ throw err;
142
+ });
143
+ } finally {
144
+ if (timeout) {
145
+ clearTimeout(timeout);
146
+ }
147
+ }
79
148
 
80
149
  if (!res.ok) {
81
150
  log.warn(`Webhook endpoint ${target_url} returned an error - ${res.status} ${res.statusText}`, {
@@ -100,6 +169,11 @@ export async function notifyWebhook(
100
169
  return { status: res.status, message: res.statusText, url: res.url };
101
170
  }
102
171
 
172
+ function hasHeader(headers: Record<string, string>, name: string): boolean {
173
+ const lowerName = name.toLowerCase();
174
+ return Object.keys(headers).some((header) => header.toLowerCase() === lowerName);
175
+ }
176
+
103
177
  // --------------------------------------
104
178
  // Data provider for webhooks
105
179
  // this allows to customize the payload sent to the webhook depending on the
@@ -93,6 +93,7 @@ export interface BulkImportParams {
93
93
  partitionSize?: number;
94
94
  dryRun?: boolean;
95
95
  updateByContentSource?: boolean;
96
+ /** @deprecated Events are now always emitted. This suppresses the Temporal-backed delivery targets (workflow, agent, and process) — webhook deliveries still fire. */
96
97
  skipWorkflows?: boolean;
97
98
  /**
98
99
  * Processing priority for the document-processing workflows triggered by created objects.
@@ -136,7 +136,7 @@ export class TextractProcessor {
136
136
  for (const cellRel of cell.Relationships) {
137
137
  if (
138
138
  cellRel.Type === 'CHILD' &&
139
- // biome-ignore lint/style/noNonNullAssertion: intentional non-null assertion; TS can't prove narrowing here
139
+ // biome-ignore lint/style/noNonNullAssertion: a Textract word block always has an Id; the SDK type marks Block.Id optional and Array.includes requires a string
140
140
  cellRel.Ids?.includes(wordBlock.Id!)
141
141
  ) {
142
142
  return true;
@@ -256,14 +256,14 @@ export class TextractProcessor {
256
256
  FeatureTypes: ['TABLES'],
257
257
  });
258
258
  const response = await this.textractClient.send(command);
259
- // biome-ignore lint/style/noNonNullAssertion: intentional non-null assertion; TS can't prove narrowing here
259
+ // biome-ignore lint/style/noNonNullAssertion: a successful StartDocumentAnalysis response always includes JobId; the SDK type marks it optional
260
260
  return response.JobId!;
261
261
  }
262
262
 
263
263
  async checkJobStatus(jobId: string): Promise<string> {
264
264
  const command = new GetDocumentAnalysisCommand({ JobId: jobId });
265
265
  const response = await this.textractClient.send(command);
266
- // biome-ignore lint/style/noNonNullAssertion: intentional non-null assertion; TS can't prove narrowing here
266
+ // biome-ignore lint/style/noNonNullAssertion: a GetDocumentAnalysis response always includes JobStatus; the SDK type marks it optional
267
267
  return response.JobStatus!;
268
268
  }
269
269
 
@@ -342,7 +342,7 @@ export class TextractProcessor {
342
342
  // Create blocks map
343
343
  const blocksMap: BlocksMap = {};
344
344
  for (const block of allBlocks) {
345
- // biome-ignore lint/style/noNonNullAssertion: intentional non-null assertion; TS can't prove narrowing here
345
+ // biome-ignore lint/style/noNonNullAssertion: every Textract block carries an Id used as the map key; the SDK type marks Block.Id optional
346
346
  blocksMap[block.Id!] = block;
347
347
  }
348
348
 
@@ -116,4 +116,28 @@ describe('ImageMagick image resizing', () => {
116
116
  // Check that interlace is disabled (should be 'none' or empty string)
117
117
  expect(['none', '']).to.include(interlaceMode);
118
118
  });
119
+
120
+ test('should apply EXIF orientation before resizing and stripping metadata', async () => {
121
+ const max_hw = 100;
122
+ const format = 'jpeg';
123
+ const inputImagePath = path.join(__dirname, '../../fixtures', 'synthetic-exif-righttop.jpg');
124
+
125
+ // Generated from synthetic pixels with only EXIF Orientation=6 added.
126
+ const { stdout: sourceStdout } = await execAsync(`identify -format "%w %h %[orientation]" "${inputImagePath}"`);
127
+ const [sourceWidth, sourceHeight, sourceOrientation] = sourceStdout.trim().split(' ');
128
+ expect(parseInt(sourceWidth, 10)).to.equal(120);
129
+ expect(parseInt(sourceHeight, 10)).to.equal(180);
130
+ expect(sourceOrientation).to.equal('RightTop');
131
+
132
+ const resizedImagePath = await imageResizer(inputImagePath, max_hw, format);
133
+
134
+ expect(fs.existsSync(resizedImagePath)).toBe(true);
135
+
136
+ const { stdout } = await execAsync(`identify -format "%w %h %[orientation]" "${resizedImagePath}"`);
137
+ const [width, height, orientation] = stdout.trim().split(' ');
138
+
139
+ expect(parseInt(width, 10)).to.equal(100);
140
+ expect(parseInt(height, 10)).to.equal(67);
141
+ expect(['Undefined', 'TopLeft']).to.include(orientation);
142
+ });
119
143
  });
@@ -84,6 +84,9 @@ export async function imageResizer(
84
84
  // Add input after JPEG shrink-on-load optimization so ImageMagick can apply it while decoding.
85
85
  args.push(inputPath);
86
86
 
87
+ // Apply EXIF orientation to pixels before stripping metadata or resizing.
88
+ args.push('-auto-orient');
89
+
87
90
  // Remove metadata
88
91
  args.push('-strip');
89
92
 
@@ -38,9 +38,9 @@ export abstract class DataProvider {
38
38
  ) {}
39
39
  async fetch(payload: FindPayload) {
40
40
  let results = await this.doFetch(payload);
41
- if (payload.select && !this.isProjectionSupported) {
42
- // biome-ignore lint/style/noNonNullAssertion: intentional non-null assertion; TS can't prove narrowing here
43
- results = results.map((result: Record<string, unknown>) => applyProjection(result, payload.select!));
41
+ const select = payload.select;
42
+ if (select && !this.isProjectionSupported) {
43
+ results = results.map((result: Record<string, unknown>) => applyProjection(result, select));
44
44
  }
45
45
  return results;
46
46
  }
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export * from './activities/advanced/createDocumentTypeFromInteractionRun.js';
10
10
  export * from './activities/advanced/createOrUpdateDocumentFromInteractionRun.js';
11
11
  export * from './activities/advanced/updateDocumentFromInteractionRun.js';
12
12
  export * from './activities/chunkDocument.js';
13
+ export * from './activities/detectDocumentLanguage.js';
13
14
  export * from './activities/executeInteraction.js';
14
15
  export * from './activities/extractDocumentText.js';
15
16
  export * from './activities/generateDocumentProperties.js';
@@ -16,9 +16,17 @@ const { notifyWebhook } = dslProxyActivities<typeof activities>('notifyWebhookWo
16
16
  });
17
17
 
18
18
  export interface NotifyWebhookWorfklowParams {
19
- workflow_type: string;
20
- endpoints: (string | WebHookSpec)[];
21
- data: Record<string, unknown>;
19
+ workflow_type?: string;
20
+ endpoints?: (string | WebHookSpec)[];
21
+ data?: Record<string, unknown>;
22
+ requests?: NotifyWebhookRequest[];
23
+ }
24
+
25
+ export interface NotifyWebhookRequest {
26
+ url: string;
27
+ body: string;
28
+ headers?: Record<string, string>;
29
+ timeout_ms?: number;
22
30
  }
23
31
 
24
32
  export async function notifyWebhookWorkflow(
@@ -32,6 +40,7 @@ export async function notifyWebhookWorkflow(
32
40
  webhook_data?: Record<string, unknown>;
33
41
  };
34
42
  const endpoints = legacyVars.endpoints ?? legacyVars.webhooks ?? [];
43
+ const requests = legacyVars.requests ?? [];
35
44
  const data = legacyVars.data ?? legacyVars.webhook_data ?? undefined;
36
45
  const workflow_type = vars.workflow_type ?? info.workflowType;
37
46
  const eventName = payload.event;
@@ -39,11 +48,29 @@ export async function notifyWebhookWorkflow(
39
48
  const workflowId = info.parent?.workflowId || info.workflowId;
40
49
  const workflowRunId = info.parent?.runId || info.runId;
41
50
 
42
- if (!endpoints.length) {
51
+ if (!endpoints.length && !requests.length) {
43
52
  log.info(`No webhooks to notify`);
44
53
  return { notifications: [], message: 'No webhooks to notify' };
45
54
  }
46
55
 
56
+ for (const request of requests) {
57
+ const n = notifyWebhook(payload, {
58
+ webhook: request.url,
59
+ method: 'POST',
60
+ workflow_type,
61
+ workflow_id: workflowId,
62
+ workflow_run_id: workflowRunId,
63
+ event_name: eventName,
64
+ body: request.body,
65
+ headers: request.headers,
66
+ timeout_ms: request.timeout_ms,
67
+ }).then((res) => {
68
+ log.info(`Webhook notified at ${request.url} with response code: ${res.status}`, { res });
69
+ return res;
70
+ });
71
+ notifications.push(n);
72
+ }
73
+
47
74
  for (const ep of endpoints) {
48
75
  const n = notifyWebhook(payload, {
49
76
  webhook: ep,