@rytass/bpm-core-nestjs-module 0.13.3 → 0.13.5

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 (143) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/package.json +14 -7
  3. package/src/index.d.ts +1 -0
  4. package/src/index.js +1 -0
  5. package/src/index.js.map +1 -1
  6. package/src/lib/attachment/attachment-storage.provider.d.ts +11 -0
  7. package/src/lib/attachment/attachment-storage.provider.js +15 -1
  8. package/src/lib/attachment/attachment-storage.provider.js.map +1 -1
  9. package/src/lib/bpm/bpm-root-options.d.ts +134 -0
  10. package/src/lib/bpm/bpm-root-options.js +61 -0
  11. package/src/lib/bpm/bpm-root-options.js.map +1 -0
  12. package/src/lib/bpm/bpm-root.module.d.ts +101 -137
  13. package/src/lib/bpm/bpm-root.module.js +83 -118
  14. package/src/lib/bpm/bpm-root.module.js.map +1 -1
  15. package/src/lib/bpm/index.d.ts +1 -0
  16. package/src/lib/bpm/index.js +1 -0
  17. package/src/lib/bpm/index.js.map +1 -1
  18. package/src/lib/calendar/business-calendar.provider.d.ts +8 -4
  19. package/src/lib/calendar/business-calendar.provider.js +15 -6
  20. package/src/lib/calendar/business-calendar.provider.js.map +1 -1
  21. package/src/lib/common/outbox.d.ts +26 -0
  22. package/src/lib/common/outbox.js +64 -0
  23. package/src/lib/common/outbox.js.map +1 -0
  24. package/src/lib/delegation/delegation.service.js +7 -7
  25. package/src/lib/delegation/delegation.service.js.map +1 -1
  26. package/src/lib/form-data-source/form-data-source.module.js +2 -4
  27. package/src/lib/form-data-source/form-data-source.module.js.map +1 -1
  28. package/src/lib/form-data-source/form-data-source.provider.d.ts +14 -0
  29. package/src/lib/form-data-source/form-data-source.provider.js +23 -0
  30. package/src/lib/form-data-source/form-data-source.provider.js.map +1 -0
  31. package/src/lib/form-data-source/index.d.ts +1 -0
  32. package/src/lib/form-data-source/index.js +1 -0
  33. package/src/lib/form-data-source/index.js.map +1 -1
  34. package/src/lib/identity/default-member-resolver.d.ts +39 -0
  35. package/src/lib/identity/default-member-resolver.js +81 -0
  36. package/src/lib/identity/default-member-resolver.js.map +1 -0
  37. package/src/lib/identity/identity.module.d.ts +12 -2
  38. package/src/lib/identity/identity.module.js +2 -1
  39. package/src/lib/identity/identity.module.js.map +1 -1
  40. package/src/lib/identity/index.d.ts +1 -0
  41. package/src/lib/identity/index.js +1 -0
  42. package/src/lib/identity/index.js.map +1 -1
  43. package/src/lib/migrations/0000000023000-workflow-webhook-deliveries.d.ts +17 -0
  44. package/src/lib/migrations/0000000023000-workflow-webhook-deliveries.js +61 -0
  45. package/src/lib/migrations/0000000023000-workflow-webhook-deliveries.js.map +1 -0
  46. package/src/lib/migrations/0000000024000-workflow-webhook-endpoints.d.ts +16 -0
  47. package/src/lib/migrations/0000000024000-workflow-webhook-endpoints.js +71 -0
  48. package/src/lib/migrations/0000000024000-workflow-webhook-endpoints.js.map +1 -0
  49. package/src/lib/migrations/index.d.ts +2 -0
  50. package/src/lib/migrations/index.js +6 -0
  51. package/src/lib/migrations/index.js.map +1 -1
  52. package/src/lib/notification/notification-delivery.service.js +4 -57
  53. package/src/lib/notification/notification-delivery.service.js.map +1 -1
  54. package/src/lib/notification/notification.service.js +4 -3
  55. package/src/lib/notification/notification.service.js.map +1 -1
  56. package/src/lib/template/template.service.d.ts +9 -0
  57. package/src/lib/template/template.service.js +31 -0
  58. package/src/lib/template/template.service.js.map +1 -1
  59. package/src/lib/template/workflow-definition.validator.js +54 -1
  60. package/src/lib/template/workflow-definition.validator.js.map +1 -1
  61. package/src/lib/workflow-engine/workflow-engine.enums.d.ts +3 -1
  62. package/src/lib/workflow-engine/workflow-engine.enums.js +2 -0
  63. package/src/lib/workflow-engine/workflow-engine.enums.js.map +1 -1
  64. package/src/lib/workflow-engine/workflow-engine.module.js +3 -0
  65. package/src/lib/workflow-engine/workflow-engine.module.js.map +1 -1
  66. package/src/lib/workflow-engine/workflow-engine.service.d.ts +5 -2
  67. package/src/lib/workflow-engine/workflow-engine.service.js +150 -26
  68. package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
  69. package/src/lib/workflow-webhook/index.d.ts +24 -0
  70. package/src/lib/workflow-webhook/index.js +28 -0
  71. package/src/lib/workflow-webhook/index.js.map +1 -0
  72. package/src/lib/workflow-webhook/workflow-webhook-allowlist.d.ts +38 -0
  73. package/src/lib/workflow-webhook/workflow-webhook-allowlist.js +296 -0
  74. package/src/lib/workflow-webhook/workflow-webhook-allowlist.js.map +1 -0
  75. package/src/lib/workflow-webhook/workflow-webhook-database-source.d.ts +33 -0
  76. package/src/lib/workflow-webhook/workflow-webhook-database-source.js +129 -0
  77. package/src/lib/workflow-webhook/workflow-webhook-database-source.js.map +1 -0
  78. package/src/lib/workflow-webhook/workflow-webhook-delivery-scheduler.service.d.ts +25 -0
  79. package/src/lib/workflow-webhook/workflow-webhook-delivery-scheduler.service.js +92 -0
  80. package/src/lib/workflow-webhook/workflow-webhook-delivery-scheduler.service.js.map +1 -0
  81. package/src/lib/workflow-webhook/workflow-webhook-delivery.entity.d.ts +53 -0
  82. package/src/lib/workflow-webhook/workflow-webhook-delivery.entity.js +97 -0
  83. package/src/lib/workflow-webhook/workflow-webhook-delivery.entity.js.map +1 -0
  84. package/src/lib/workflow-webhook/workflow-webhook-delivery.enums.d.ts +28 -0
  85. package/src/lib/workflow-webhook/workflow-webhook-delivery.enums.js +36 -0
  86. package/src/lib/workflow-webhook/workflow-webhook-delivery.enums.js.map +1 -0
  87. package/src/lib/workflow-webhook/workflow-webhook-delivery.object.d.ts +28 -0
  88. package/src/lib/workflow-webhook/workflow-webhook-delivery.object.js +88 -0
  89. package/src/lib/workflow-webhook/workflow-webhook-delivery.object.js.map +1 -0
  90. package/src/lib/workflow-webhook/workflow-webhook-delivery.resolver.d.ts +9 -0
  91. package/src/lib/workflow-webhook/workflow-webhook-delivery.resolver.js +66 -0
  92. package/src/lib/workflow-webhook/workflow-webhook-delivery.resolver.js.map +1 -0
  93. package/src/lib/workflow-webhook/workflow-webhook-delivery.service.d.ts +116 -0
  94. package/src/lib/workflow-webhook/workflow-webhook-delivery.service.js +705 -0
  95. package/src/lib/workflow-webhook/workflow-webhook-delivery.service.js.map +1 -0
  96. package/src/lib/workflow-webhook/workflow-webhook-delivery.subscriber.d.ts +23 -0
  97. package/src/lib/workflow-webhook/workflow-webhook-delivery.subscriber.js +85 -0
  98. package/src/lib/workflow-webhook/workflow-webhook-delivery.subscriber.js.map +1 -0
  99. package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.dto.d.ts +85 -0
  100. package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.dto.js +345 -0
  101. package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.dto.js.map +1 -0
  102. package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.resolver.d.ts +23 -0
  103. package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.resolver.js +159 -0
  104. package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.resolver.js.map +1 -0
  105. package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.service.d.ts +93 -0
  106. package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.service.js +464 -0
  107. package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.service.js.map +1 -0
  108. package/src/lib/workflow-webhook/workflow-webhook-endpoint.entity.d.ts +52 -0
  109. package/src/lib/workflow-webhook/workflow-webhook-endpoint.entity.js +135 -0
  110. package/src/lib/workflow-webhook/workflow-webhook-endpoint.entity.js.map +1 -0
  111. package/src/lib/workflow-webhook/workflow-webhook-enqueue.d.ts +46 -0
  112. package/src/lib/workflow-webhook/workflow-webhook-enqueue.js +134 -0
  113. package/src/lib/workflow-webhook/workflow-webhook-enqueue.js.map +1 -0
  114. package/src/lib/workflow-webhook/workflow-webhook-options.d.ts +86 -0
  115. package/src/lib/workflow-webhook/workflow-webhook-options.js +88 -0
  116. package/src/lib/workflow-webhook/workflow-webhook-options.js.map +1 -0
  117. package/src/lib/workflow-webhook/workflow-webhook-options.module.d.ts +18 -0
  118. package/src/lib/workflow-webhook/workflow-webhook-options.module.js +57 -0
  119. package/src/lib/workflow-webhook/workflow-webhook-options.module.js.map +1 -0
  120. package/src/lib/workflow-webhook/workflow-webhook-secret-cipher.d.ts +20 -0
  121. package/src/lib/workflow-webhook/workflow-webhook-secret-cipher.js +66 -0
  122. package/src/lib/workflow-webhook/workflow-webhook-secret-cipher.js.map +1 -0
  123. package/src/lib/workflow-webhook/workflow-webhook.errors.d.ts +35 -0
  124. package/src/lib/workflow-webhook/workflow-webhook.errors.js +41 -0
  125. package/src/lib/workflow-webhook/workflow-webhook.errors.js.map +1 -0
  126. package/src/lib/workflow-webhook/workflow-webhook.module.d.ts +9 -0
  127. package/src/lib/workflow-webhook/workflow-webhook.module.js +62 -0
  128. package/src/lib/workflow-webhook/workflow-webhook.module.js.map +1 -0
  129. package/src/lib/workflow-webhook/workflow-webhook.provider.d.ts +12 -0
  130. package/src/lib/workflow-webhook/workflow-webhook.provider.js +21 -0
  131. package/src/lib/workflow-webhook/workflow-webhook.provider.js.map +1 -0
  132. package/src/lib/workflow-webhook/workflow-webhook.queries.d.ts +38 -0
  133. package/src/lib/workflow-webhook/workflow-webhook.queries.js +142 -0
  134. package/src/lib/workflow-webhook/workflow-webhook.queries.js.map +1 -0
  135. package/src/lib/workflow-webhook/workflow-webhook.service.d.ts +50 -0
  136. package/src/lib/workflow-webhook/workflow-webhook.service.js +221 -0
  137. package/src/lib/workflow-webhook/workflow-webhook.service.js.map +1 -0
  138. package/src/lib/workflow-webhook/workflow-webhook.types.d.ts +127 -0
  139. package/src/lib/workflow-webhook/workflow-webhook.types.js +33 -0
  140. package/src/lib/workflow-webhook/workflow-webhook.types.js.map +1 -0
  141. package/src/lib/workflow-webhook/workflow-webhook.validator.d.ts +20 -0
  142. package/src/lib/workflow-webhook/workflow-webhook.validator.js +97 -0
  143. package/src/lib/workflow-webhook/workflow-webhook.validator.js.map +1 -0
@@ -0,0 +1,705 @@
1
+ "use strict";
2
+ var WorkflowWebhookDeliveryService_1;
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.WorkflowWebhookDeliveryService = exports.BPM_WORKFLOW_WEBHOOK_FETCH = void 0;
5
+ const tslib_1 = require("tslib");
6
+ const node_crypto_1 = require("node:crypto");
7
+ const common_1 = require("@nestjs/common");
8
+ const typeorm_1 = require("@nestjs/typeorm");
9
+ const typeorm_2 = require("typeorm");
10
+ const outbox_1 = require("../common/outbox");
11
+ const activity_log_entity_1 = require("../workflow-engine/activity-log.entity");
12
+ const workflow_engine_enums_1 = require("../workflow-engine/workflow-engine.enums");
13
+ const workflow_graph_1 = require("@rytass/bpm-core-shared/workflow-graph");
14
+ const workflow_webhook_allowlist_1 = require("./workflow-webhook-allowlist");
15
+ const workflow_webhook_delivery_enums_1 = require("./workflow-webhook-delivery.enums");
16
+ const workflow_webhook_delivery_entity_1 = require("./workflow-webhook-delivery.entity");
17
+ const workflow_webhook_enqueue_1 = require("./workflow-webhook-enqueue");
18
+ const workflow_webhook_options_1 = require("./workflow-webhook-options");
19
+ const workflow_webhook_service_1 = require("./workflow-webhook.service");
20
+ /**
21
+ * The HTTP client deliveries go through. Defaults to the global `fetch`;
22
+ * bound mainly so tests can observe requests without a network.
23
+ */
24
+ exports.BPM_WORKFLOW_WEBHOOK_FETCH = Symbol('BPM_WORKFLOW_WEBHOOK_FETCH');
25
+ /** How much of a failing response body an administrator gets to see. */
26
+ const MAX_ERROR_DETAIL_LENGTH = 500;
27
+ /** Bytes read from a failing response before the rest is discarded. */
28
+ const MAX_ERROR_BODY_BYTES = 4_096;
29
+ const ALLOWED_METHODS = ['PATCH', 'POST', 'PUT'];
30
+ /** Deliveries from one claimed batch that are attempted at the same time. */
31
+ const MAX_CONCURRENT_ATTEMPTS = 5;
32
+ /**
33
+ * A claimed row whose attempt never wrote back is reclaimable after this. Rows
34
+ * are claimed no more than {@link MAX_CONCURRENT_ATTEMPTS} at a time and all of
35
+ * them start at once, so no claimed row ever waits in a queue: the window only
36
+ * has to cover one `buildRequest()` plus one request (30 s each at most) and
37
+ * the write.
38
+ */
39
+ const STALE_CLAIM_MS = workflow_webhook_options_1.WORKFLOW_WEBHOOK_MAX_TIMEOUT_MS * 3;
40
+ let WorkflowWebhookDeliveryService = WorkflowWebhookDeliveryService_1 = class WorkflowWebhookDeliveryService {
41
+ constructor(deliveryRepository, activityLogRepository, webhookService, options = workflow_webhook_options_1.DEFAULT_BPM_WORKFLOW_WEBHOOK_OPTIONS, fetchImpl) {
42
+ this.deliveryRepository = deliveryRepository;
43
+ this.activityLogRepository = activityLogRepository;
44
+ this.webhookService = webhookService;
45
+ this.options = options;
46
+ this.logger = new common_1.Logger(WorkflowWebhookDeliveryService_1.name);
47
+ this.fetchImpl =
48
+ fetchImpl ??
49
+ ((input, init) => fetch(input, init));
50
+ }
51
+ /** The clock every attempt reads; separate so tests can pin it. */
52
+ readCurrentTime() {
53
+ return new Date();
54
+ }
55
+ /**
56
+ * Queues a NOTIFY node's webhooks inside the engine transaction `manager`.
57
+ * Sends nothing: delivery starts only once that transaction commits
58
+ * (ADR 18 §3.5). Rows that can already be judged undeliverable are written
59
+ * as `FAILED` together with their terminal activity log.
60
+ */
61
+ async enqueueNotifyWebhooks(manager, context, targets) {
62
+ const listIssues = (0, workflow_graph_1.readNotifyWebhookStructureIssues)(targets).filter((issue) => issue.targetIndex === null);
63
+ // Publish refuses these, so reaching one means the snapshot was written
64
+ // around the lint. Nothing can be queued, but it must not pass silently.
65
+ if (listIssues.length) {
66
+ this.logger.warn(`Instance ${context.instance.id} node ${context.node.id}: webhooks not queued (${listIssues
67
+ .map((issue) => issue.code)
68
+ .join(', ')})`);
69
+ }
70
+ const drafts = await (0, workflow_webhook_enqueue_1.buildWorkflowWebhookDeliveryDrafts)({
71
+ context,
72
+ resolveEndpoint: (key, version) => this.webhookService.getEndpoint(key, version, manager),
73
+ targets,
74
+ });
75
+ if (!drafts.length) {
76
+ return [];
77
+ }
78
+ const repository = manager.getRepository(workflow_webhook_delivery_entity_1.WorkflowWebhookDeliveryEntity);
79
+ const saved = await repository.save(drafts.map((draft) => repository.create({ ...draft, attemptCount: 0 })));
80
+ const failed = saved.filter((row) => row.status === workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.FAILED);
81
+ if (failed.length) {
82
+ const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
83
+ const activities = await Promise.all(failed.map(async (row) => activityRepository.create(this.createTerminalActivity(row, await this.readEndpointLabel(row, manager)))));
84
+ await activityRepository.save(activities);
85
+ }
86
+ return saved.map((row) => row.id);
87
+ }
88
+ /** Every delivery queued for an instance, oldest first (ADR 18 §3.10). */
89
+ async listInstanceDeliveries(instanceId) {
90
+ return this.deliveryRepository.find({
91
+ order: { createdAt: 'ASC' },
92
+ where: { instanceId },
93
+ });
94
+ }
95
+ /**
96
+ * Puts a `FAILED` delivery back in the queue with a fresh attempt budget
97
+ * and starts it at once. The delivery id does not change, so a receiver
98
+ * that already processed an earlier attempt still recognizes it.
99
+ *
100
+ * Only `FAILED` rows that were actually attempted qualify. A row in flight
101
+ * or already sent would be sent again; a row that failed while being queued
102
+ * (endpoint missing, lookup failed, parameter invalid) froze an event that
103
+ * never passed the parameter checks, and sending it would break the
104
+ * endpoint's contract. The state change and the audit entry commit together.
105
+ */
106
+ async retryFailedDelivery(id, actorMemberId) {
107
+ const existing = await this.deliveryRepository.findOne({ where: { id } });
108
+ if (!existing) {
109
+ throw new common_1.NotFoundException(`Webhook delivery ${id} was not found`);
110
+ }
111
+ // Read before the transaction: a host lookup has no business holding the
112
+ // row lock or a second pooled connection.
113
+ const endpointLabel = await this.readEndpointLabel(existing);
114
+ const retried = await this.deliveryRepository.manager.transaction(async (manager) => {
115
+ const repository = manager.getRepository(workflow_webhook_delivery_entity_1.WorkflowWebhookDeliveryEntity);
116
+ const result = await repository.update({
117
+ attemptCount: (0, typeorm_2.MoreThan)(0),
118
+ id,
119
+ status: workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.FAILED,
120
+ }, {
121
+ attemptCount: 0,
122
+ nextRetryAt: null,
123
+ status: workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.PENDING,
124
+ });
125
+ const row = await repository.findOne({ where: { id } });
126
+ if (!row) {
127
+ throw new common_1.NotFoundException(`Webhook delivery ${id} was not found`);
128
+ }
129
+ if (!result.affected) {
130
+ throw new common_1.BadRequestException(row.status === workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.FAILED
131
+ ? `Webhook delivery ${id} failed before it was ever sent (${row.lastErrorCode ?? 'unknown'}) and cannot be retried`
132
+ : `Webhook delivery ${id} is ${row.status}; only FAILED deliveries can be retried`);
133
+ }
134
+ const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
135
+ await activityRepository.save(activityRepository.create({
136
+ actorMemberId,
137
+ eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.WEBHOOK_DELIVERY_RETRIED,
138
+ instanceId: row.instanceId,
139
+ nodeId: row.nodeId,
140
+ payload: {
141
+ action: 'NOTIFY_WEBHOOK',
142
+ deliveryId: row.id,
143
+ endpointKey: row.endpointKey,
144
+ endpointLabel,
145
+ endpointVersion: row.endpointVersion,
146
+ previousErrorCode: row.lastErrorCode,
147
+ targetId: row.targetId,
148
+ },
149
+ taskId: null,
150
+ }));
151
+ return row;
152
+ });
153
+ // After the commit, like the subscriber's kick: the scheduler would get
154
+ // there too, but an administrator who pressed retry expects an answer now.
155
+ setImmediate(() => {
156
+ this.deliverByIds([retried.id]).catch((error) => {
157
+ this.logger.warn(`Retried webhook delivery ${retried.id} could not start immediately (${readErrorName(error)})`);
158
+ });
159
+ });
160
+ return retried;
161
+ }
162
+ /**
163
+ * One scheduler pass: claim what is due, a few rows at a time, until the
164
+ * batch size is reached or nothing more is due.
165
+ *
166
+ * Claiming the whole batch up front and attempting it a few at a time left
167
+ * the rows at the back holding a claim while they waited; past the stale
168
+ * window another worker took them over. Claiming only what starts at once
169
+ * means a claimed row is always a row in flight.
170
+ */
171
+ async deliverDue(now) {
172
+ const { batchSize } = this.options.delivery;
173
+ let total = 0;
174
+ while (total < batchSize) {
175
+ const claimed = await this.claim({
176
+ ids: null,
177
+ limit: Math.min(MAX_CONCURRENT_ATTEMPTS, batchSize - total),
178
+ now: total === 0 && now ? now : this.readCurrentTime(),
179
+ });
180
+ if (!claimed.length) {
181
+ break;
182
+ }
183
+ await this.attemptTogether(claimed);
184
+ total += claimed.length;
185
+ }
186
+ return total;
187
+ }
188
+ /**
189
+ * Attempts specific rows right after the transaction that queued them
190
+ * committed, in groups that start together. Rows another worker already
191
+ * claimed, or that are not due, are skipped by the claim itself.
192
+ */
193
+ async deliverByIds(ids, now) {
194
+ const unique = [...new Set(ids)];
195
+ let total = 0;
196
+ for (let start = 0; start < unique.length; start += MAX_CONCURRENT_ATTEMPTS) {
197
+ const group = unique.slice(start, start + MAX_CONCURRENT_ATTEMPTS);
198
+ const claimed = await this.claim({
199
+ ids: group,
200
+ limit: group.length,
201
+ now: start === 0 && now ? now : this.readCurrentTime(),
202
+ });
203
+ await this.attemptTogether(claimed);
204
+ total += claimed.length;
205
+ }
206
+ return total;
207
+ }
208
+ /**
209
+ * Starts every claimed row at once (never more than
210
+ * {@link MAX_CONCURRENT_ATTEMPTS}). Strictly one by one, a single receiver
211
+ * that runs into its timeout held every other delivery back by that long
212
+ * (seen on the wrapper host: a 10 s timeout delayed an unrelated endpoint by
213
+ * 10 s). Each row still re-stamps and writes back under its own claim.
214
+ */
215
+ async attemptTogether(rows) {
216
+ await Promise.all(rows.map((row) => this.attemptSafely(row)));
217
+ }
218
+ async attemptSafely(row) {
219
+ try {
220
+ await this.attempt(row);
221
+ }
222
+ catch (error) {
223
+ // Only a failed write can land here. The row stays claimed and the
224
+ // stale window hands it to a later scan. The error's name only: a
225
+ // driver message can quote the parameters it was given.
226
+ this.logger.error(`Webhook delivery ${row.id} could not record its outcome (${readErrorName(error)})`);
227
+ }
228
+ }
229
+ async claim({ ids, limit, now, }) {
230
+ const staleBefore = new Date(now.getTime() - STALE_CLAIM_MS);
231
+ return this.deliveryRepository.manager.transaction(async (manager) => {
232
+ const claimedRows = (await manager.query(`
233
+ UPDATE workflow_webhook_deliveries
234
+ SET status = $1,
235
+ last_attempt_at = $2,
236
+ updated_at = $2
237
+ WHERE id IN (
238
+ SELECT id
239
+ FROM workflow_webhook_deliveries
240
+ WHERE (
241
+ (status = $3 AND (next_retry_at IS NULL OR next_retry_at <= $2))
242
+ OR (status = $1 AND last_attempt_at <= $4)
243
+ )
244
+ AND ($6::uuid[] IS NULL OR id = ANY($6::uuid[]))
245
+ ORDER BY created_at ASC
246
+ FOR UPDATE SKIP LOCKED
247
+ LIMIT $5
248
+ )
249
+ RETURNING id
250
+ `, [
251
+ workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.DELIVERY_IN_PROGRESS,
252
+ now,
253
+ workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.PENDING,
254
+ staleBefore,
255
+ limit,
256
+ ids ? [...ids] : null,
257
+ ]));
258
+ const claimedIds = (0, outbox_1.readClaimedIds)(claimedRows);
259
+ return claimedIds.length
260
+ ? manager
261
+ .getRepository(workflow_webhook_delivery_entity_1.WorkflowWebhookDeliveryEntity)
262
+ .find({ where: { id: (0, typeorm_2.In)([...claimedIds]) } })
263
+ : [];
264
+ });
265
+ }
266
+ async attempt(row) {
267
+ const startedAt = this.readCurrentTime();
268
+ // Re-stamping at the start of the attempt confirms this worker still owns
269
+ // the row: if another one reclaimed it between the claim and this line,
270
+ // the stamp no longer matches and this attempt stands down instead of
271
+ // sending a second time.
272
+ if (!(await this.moveClaim(row, row.lastAttemptAt, startedAt))) {
273
+ return;
274
+ }
275
+ const claimed = {
276
+ ...row,
277
+ lastAttemptAt: startedAt,
278
+ };
279
+ const attempt = row.attemptCount + 1;
280
+ const outcome = await this.resolveOutcome(claimed, attempt).catch((error) => ({
281
+ code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.INTERNAL_ERROR,
282
+ detail: readErrorName(error),
283
+ kind: 'RETRY',
284
+ status: null,
285
+ }));
286
+ await this.record(claimed, attempt, outcome, this.readCurrentTime());
287
+ }
288
+ async resolveOutcome(row, attempt) {
289
+ const lookup = await this.webhookService
290
+ .getEndpoint(row.endpointKey, row.endpointVersion)
291
+ .then((entry) => ({ entry }), (error) => ({ error }));
292
+ if ('error' in lookup) {
293
+ return {
294
+ code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.ENDPOINT_LOOKUP_FAILED,
295
+ detail: readErrorName(lookup.error),
296
+ kind: 'RETRY',
297
+ status: null,
298
+ };
299
+ }
300
+ if (!lookup.entry) {
301
+ return {
302
+ code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.ENDPOINT_MISSING,
303
+ detail: `${row.endpointKey}@${row.endpointVersion} is not registered`,
304
+ kind: 'FAIL',
305
+ status: null,
306
+ };
307
+ }
308
+ // Checked per attempt: disabling an endpoint stops what is already queued.
309
+ if (lookup.entry.endpoint.descriptor.disabled) {
310
+ return {
311
+ code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.ENDPOINT_DISABLED,
312
+ detail: `${row.endpointKey}@${row.endpointVersion} is disabled`,
313
+ kind: 'FAIL',
314
+ status: null,
315
+ };
316
+ }
317
+ return this.sendEvent(lookup.entry, {
318
+ ...row.event,
319
+ attempt,
320
+ deliveryId: row.id,
321
+ eventType: 'workflow.notify',
322
+ });
323
+ }
324
+ /**
325
+ * Sends one event to an endpoint exactly as a delivery attempt would —
326
+ * same refusals, allowlist, timeout, redirect handling and signature —
327
+ * without a delivery row, for an administrator's test send (ADR 18 §3.13).
328
+ * The endpoint may be disabled: testing before enabling is the point.
329
+ */
330
+ async sendTestEvent(entry, event) {
331
+ const outcome = await this.sendEvent(entry, event).catch((error) => ({
332
+ code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.INTERNAL_ERROR,
333
+ detail: readErrorName(error),
334
+ kind: 'FAIL',
335
+ status: null,
336
+ }));
337
+ return outcome.kind === 'SENT'
338
+ ? { errorCode: null, errorDetail: null, ok: true, status: outcome.status }
339
+ : {
340
+ errorCode: outcome.code,
341
+ errorDetail: outcome.detail ? truncate(outcome.detail) : null,
342
+ ok: false,
343
+ status: outcome.status,
344
+ };
345
+ }
346
+ async sendEvent(entry, event) {
347
+ // Wrapped so a host that throws synchronously, or returns a non-promise,
348
+ // is judged like any other failure instead of escaping the attempt.
349
+ const built = await (0, outbox_1.withDispatchTimeout)(Promise.resolve().then(() => entry.endpoint.buildRequest(event)), workflow_webhook_options_1.WORKFLOW_WEBHOOK_MAX_TIMEOUT_MS).then((value) => ({ value }), (error) => ({ error }));
350
+ if ('error' in built) {
351
+ return {
352
+ code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.BUILD_REQUEST_FAILED,
353
+ // The host's message may name the secret it failed to read, so only
354
+ // the error's kind is kept.
355
+ detail: readErrorName(built.error),
356
+ kind: 'RETRY',
357
+ status: null,
358
+ };
359
+ }
360
+ if (!isWebhookRequest(built.value)) {
361
+ return {
362
+ code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.INVALID_REQUEST,
363
+ detail: 'buildRequest did not return a request with a string url',
364
+ kind: 'FAIL',
365
+ status: null,
366
+ };
367
+ }
368
+ const request = built.value;
369
+ const refusal = this.readRequestRefusal(request, entry);
370
+ if (refusal) {
371
+ return refusal;
372
+ }
373
+ const body = request.body ?? JSON.stringify(event);
374
+ const timeoutMs = Math.min(typeof request.timeoutMs === 'number' && request.timeoutMs > 0
375
+ ? request.timeoutMs
376
+ : this.options.delivery.defaultTimeoutMs, workflow_webhook_options_1.WORKFLOW_WEBHOOK_MAX_TIMEOUT_MS);
377
+ return this.post({
378
+ body,
379
+ // Signed at the moment of sending, not when the row was claimed, so a
380
+ // receiver's replay window measures the request it actually got.
381
+ headers: buildHeaders(request, body, event.deliveryId, this.readCurrentTime()),
382
+ method: request.method ?? 'POST',
383
+ timeoutMs,
384
+ url: request.url,
385
+ });
386
+ }
387
+ /**
388
+ * A request BPM refuses to send at all. These never become retryable: the
389
+ * same endpoint would return the same request next time. None of the
390
+ * details quote the URL.
391
+ */
392
+ readRequestRefusal(request, entry) {
393
+ const invalid = (detail) => ({
394
+ code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.INVALID_REQUEST,
395
+ detail,
396
+ kind: 'FAIL',
397
+ status: null,
398
+ });
399
+ const parsed = (() => {
400
+ try {
401
+ return new URL(request.url);
402
+ }
403
+ catch {
404
+ return null;
405
+ }
406
+ })();
407
+ if (!parsed ||
408
+ (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')) {
409
+ return invalid('buildRequest returned a URL that is not http(s)');
410
+ }
411
+ if (parsed.username || parsed.password) {
412
+ return invalid('buildRequest returned a URL that carries credentials');
413
+ }
414
+ if (request.method !== undefined &&
415
+ !ALLOWED_METHODS.includes(String(request.method))) {
416
+ return invalid('buildRequest returned a method other than POST, PUT or PATCH');
417
+ }
418
+ if (request.body !== undefined && typeof request.body !== 'string') {
419
+ return invalid('buildRequest returned a body that is not a string');
420
+ }
421
+ const enforceAllowlist = entry.source === 'DATABASE' || this.options.enforceAllowlistForRegistry;
422
+ // Checked on every attempt, not only when the endpoint was saved, so
423
+ // tightening the allowlist stops deliveries that are already queued.
424
+ if (enforceAllowlist &&
425
+ !(0, workflow_webhook_allowlist_1.isWorkflowWebhookUrlAllowed)(request.url, this.options.allowedUrlPatterns)) {
426
+ return {
427
+ code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.URL_NOT_ALLOWED,
428
+ detail: null,
429
+ kind: 'FAIL',
430
+ status: null,
431
+ };
432
+ }
433
+ return null;
434
+ }
435
+ async post({ body, headers, method, timeoutMs, url, }) {
436
+ const controller = new AbortController();
437
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
438
+ timer.unref();
439
+ try {
440
+ const response = await this.fetchImpl(url, {
441
+ body,
442
+ headers,
443
+ method,
444
+ // A 3xx is a failure, never followed: an allowed host must not be
445
+ // able to bounce the request somewhere the allowlist would refuse.
446
+ redirect: 'manual',
447
+ signal: controller.signal,
448
+ });
449
+ const status = response.status;
450
+ if (status >= 200 && status < 300) {
451
+ await discardBody(response);
452
+ return { kind: 'SENT', status };
453
+ }
454
+ // `redirect: 'manual'` surfaces as an opaque redirect with status 0 in
455
+ // spec-compliant fetch implementations and as the raw 3xx in Node's.
456
+ if (response.type === 'opaqueredirect' ||
457
+ (status >= 300 && status < 400)) {
458
+ await discardBody(response);
459
+ return {
460
+ code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.REDIRECT,
461
+ detail: null,
462
+ kind: 'FAIL',
463
+ status: status || null,
464
+ };
465
+ }
466
+ const detail = truncate(await readBodyPrefix(response));
467
+ return {
468
+ code: `WEBHOOK_HTTP_${status}`,
469
+ detail: detail || null,
470
+ kind: isRetryableStatus(status) ? 'RETRY' : 'FAIL',
471
+ status,
472
+ };
473
+ }
474
+ catch (error) {
475
+ const aborted = controller.signal.aborted ||
476
+ (error instanceof Error && error.name === 'AbortError');
477
+ return {
478
+ code: aborted
479
+ ? workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.TIMEOUT
480
+ : workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.NETWORK,
481
+ // A fetch error message can quote the URL; the system error code
482
+ // (ECONNREFUSED, ENOTFOUND, ...) says what happened without it.
483
+ detail: aborted ? null : readNetworkErrorCode(error),
484
+ kind: 'RETRY',
485
+ status: null,
486
+ };
487
+ }
488
+ finally {
489
+ clearTimeout(timer);
490
+ }
491
+ }
492
+ async record(row, attempt, outcome, finishedAt) {
493
+ const exhausted = outcome.kind === 'RETRY' && attempt >= this.options.delivery.maxAttempts;
494
+ const terminal = outcome.kind !== 'RETRY' || exhausted;
495
+ const changes = outcome.kind === 'SENT'
496
+ ? {
497
+ attemptCount: attempt,
498
+ lastErrorCode: null,
499
+ lastErrorDetail: null,
500
+ lastResponseStatus: outcome.status,
501
+ nextRetryAt: null,
502
+ sentAt: finishedAt,
503
+ status: workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.SENT,
504
+ }
505
+ : {
506
+ attemptCount: attempt,
507
+ lastErrorCode: outcome.code,
508
+ // Every detail source passes through here, so every one is made
509
+ // storable — including a host error's `name`.
510
+ lastErrorDetail: outcome.detail === null ? null : truncate(String(outcome.detail)),
511
+ lastResponseStatus: outcome.status,
512
+ nextRetryAt: terminal
513
+ ? null
514
+ : new Date(finishedAt.getTime() + this.readRetryDelay(attempt)),
515
+ status: terminal
516
+ ? workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.FAILED
517
+ : workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.PENDING,
518
+ };
519
+ // Written only if this worker still holds the claim it started with, so a
520
+ // late write-back can never overwrite what another worker already
521
+ // recorded — a SENT row least of all.
522
+ const result = await this.deliveryRepository.update({
523
+ id: row.id,
524
+ lastAttemptAt: row.lastAttemptAt ?? (0, typeorm_2.IsNull)(),
525
+ status: workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.DELIVERY_IN_PROGRESS,
526
+ }, changes);
527
+ if (!result.affected) {
528
+ this.logger.warn(`Webhook delivery ${row.id} was reclaimed before attempt ${attempt} finished; its outcome was discarded`);
529
+ return;
530
+ }
531
+ // Intermediate retries stay off the instance timeline; only the final
532
+ // word about a delivery is worth a reader's attention (ADR 18 §3.10).
533
+ if (terminal) {
534
+ // The outcome is already recorded; a failed timeline write must not be
535
+ // reported as if the delivery itself were unrecorded.
536
+ const endpointLabel = await this.readEndpointLabel(row);
537
+ await this.activityLogRepository
538
+ .save(this.activityLogRepository.create(this.createTerminalActivity({ ...row, ...changes }, endpointLabel)))
539
+ .catch((error) => {
540
+ this.logger.error(`Webhook delivery ${row.id} is ${changes.status} but its activity log could not be written (${readErrorName(error)})`);
541
+ });
542
+ }
543
+ }
544
+ /**
545
+ * The endpoint's display name for the timeline, which every reader of the
546
+ * instance sees; `null` when the endpoint is gone and the key must stand in.
547
+ */
548
+ async readEndpointLabel(row, manager) {
549
+ // Every step is guarded: a host entry missing its descriptor must cost
550
+ // the label, not the activity log it is written into.
551
+ return Promise.resolve()
552
+ .then(() => this.webhookService.getEndpoint(row.endpointKey, row.endpointVersion, manager))
553
+ .then((entry) => {
554
+ const label = entry?.endpoint?.descriptor?.label;
555
+ return typeof label === 'string' ? label : null;
556
+ })
557
+ .catch(() => null);
558
+ }
559
+ /** Takes the claim over from `from` to `to`; `false` if it moved on. */
560
+ async moveClaim(row, from, to) {
561
+ const result = await this.deliveryRepository.update({
562
+ id: row.id,
563
+ lastAttemptAt: from ?? (0, typeorm_2.IsNull)(),
564
+ status: workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.DELIVERY_IN_PROGRESS,
565
+ }, { lastAttemptAt: to });
566
+ return Boolean(result.affected);
567
+ }
568
+ /**
569
+ * `base * 2^(attempt - 1)` with ±20 % jitter so a receiver that came back up
570
+ * is not hit by every queued delivery in the same second, then capped: the
571
+ * cap is a promise to the operator, so jitter may not exceed it.
572
+ */
573
+ readRetryDelay(attempt) {
574
+ const { maxRetryDelayMs, retryBaseDelayMs } = this.options.delivery;
575
+ const exponential = retryBaseDelayMs * 2 ** Math.min(Math.max(attempt - 1, 0), 30);
576
+ return Math.min(Math.round(exponential * (0.8 + Math.random() * 0.4)), maxRetryDelayMs);
577
+ }
578
+ /**
579
+ * Visible to everyone who can read the instance, so it carries the outcome
580
+ * and nothing else: no URL, no response body, no parameters.
581
+ */
582
+ createTerminalActivity(row, endpointLabel) {
583
+ return {
584
+ actorMemberId: null,
585
+ eventType: row.status === workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.SENT
586
+ ? workflow_engine_enums_1.ActivityLogEventTypeEnum.SERVICE_TASK_EXECUTED
587
+ : workflow_engine_enums_1.ActivityLogEventTypeEnum.SERVICE_TASK_FAILED,
588
+ instanceId: row.instanceId,
589
+ nodeId: row.nodeId,
590
+ payload: {
591
+ action: 'NOTIFY_WEBHOOK',
592
+ attempts: row.attemptCount,
593
+ deliveryId: row.id,
594
+ endpointKey: row.endpointKey,
595
+ endpointLabel,
596
+ endpointVersion: row.endpointVersion,
597
+ errorCode: row.lastErrorCode,
598
+ status: row.lastResponseStatus,
599
+ targetId: row.targetId,
600
+ },
601
+ taskId: null,
602
+ };
603
+ }
604
+ };
605
+ exports.WorkflowWebhookDeliveryService = WorkflowWebhookDeliveryService;
606
+ exports.WorkflowWebhookDeliveryService = WorkflowWebhookDeliveryService = WorkflowWebhookDeliveryService_1 = tslib_1.__decorate([
607
+ (0, common_1.Injectable)(),
608
+ tslib_1.__param(0, (0, typeorm_1.InjectRepository)(workflow_webhook_delivery_entity_1.WorkflowWebhookDeliveryEntity)),
609
+ tslib_1.__param(1, (0, typeorm_1.InjectRepository)(activity_log_entity_1.ActivityLogEntity)),
610
+ tslib_1.__param(3, (0, common_1.Optional)()),
611
+ tslib_1.__param(3, (0, common_1.Inject)(workflow_webhook_options_1.BPM_WORKFLOW_WEBHOOK_OPTIONS)),
612
+ tslib_1.__param(4, (0, common_1.Optional)()),
613
+ tslib_1.__param(4, (0, common_1.Inject)(exports.BPM_WORKFLOW_WEBHOOK_FETCH)),
614
+ tslib_1.__metadata("design:paramtypes", [typeorm_2.Repository,
615
+ typeorm_2.Repository,
616
+ workflow_webhook_service_1.WorkflowWebhookService, Object, Function])
617
+ ], WorkflowWebhookDeliveryService);
618
+ function isWebhookRequest(value) {
619
+ return (typeof value === 'object' &&
620
+ value !== null &&
621
+ typeof value.url === 'string');
622
+ }
623
+ function buildHeaders(request, body, deliveryId, sentAt) {
624
+ const hostHeaders = Object.fromEntries(Object.entries(request.headers ?? {}).filter(([name, value]) => typeof value === 'string' && !name.toLowerCase().startsWith('x-bpm-')));
625
+ const hasContentType = Object.keys(hostHeaders).some((name) => name.toLowerCase() === 'content-type');
626
+ const timestamp = String(Math.floor(sentAt.getTime() / 1000));
627
+ // BPM's own headers are applied last and a host cannot supply an
628
+ // `x-bpm-*` header, so a receiver can trust them.
629
+ return {
630
+ ...(hasContentType ? {} : { 'content-type': 'application/json' }),
631
+ ...hostHeaders,
632
+ 'x-bpm-delivery-id': deliveryId,
633
+ 'x-bpm-event': 'workflow.notify',
634
+ ...(request.signingSecret
635
+ ? {
636
+ 'x-bpm-signature-sha256': (0, node_crypto_1.createHmac)('sha256', request.signingSecret)
637
+ .update(`${timestamp}.${body}`)
638
+ .digest('hex'),
639
+ 'x-bpm-timestamp': timestamp,
640
+ }
641
+ : {}),
642
+ };
643
+ }
644
+ /** Releases the connection without reading a body nobody will look at. */
645
+ async function discardBody(response) {
646
+ await response.body?.cancel().catch(() => undefined);
647
+ }
648
+ /**
649
+ * Reads at most {@link MAX_ERROR_BODY_BYTES} of a failing response, so a
650
+ * receiver answering with megabytes of HTML cannot make BPM buffer them.
651
+ */
652
+ async function readBodyPrefix(response) {
653
+ const reader = response.body?.getReader();
654
+ if (!reader) {
655
+ return response.text().catch(() => '');
656
+ }
657
+ const chunks = [];
658
+ let received = 0;
659
+ try {
660
+ while (received < MAX_ERROR_BODY_BYTES) {
661
+ const { done, value } = await reader.read();
662
+ if (done || !value) {
663
+ break;
664
+ }
665
+ chunks.push(value);
666
+ received += value.byteLength;
667
+ }
668
+ }
669
+ catch {
670
+ // A body that fails mid-read still says something; keep what arrived.
671
+ }
672
+ finally {
673
+ await reader.cancel().catch(() => undefined);
674
+ }
675
+ return new TextDecoder()
676
+ .decode(Buffer.concat(chunks).subarray(0, MAX_ERROR_BODY_BYTES))
677
+ .replace(/�+$/u, '');
678
+ }
679
+ function readErrorName(error) {
680
+ return error instanceof Error ? error.name : typeof error;
681
+ }
682
+ function readNetworkErrorCode(error) {
683
+ const cause = typeof error === 'object' && error !== null && 'cause' in error
684
+ ? error.cause
685
+ : undefined;
686
+ const code = typeof cause === 'object' && cause !== null && 'code' in cause
687
+ ? cause.code
688
+ : undefined;
689
+ return typeof code === 'string' ? code : readErrorName(error);
690
+ }
691
+ function isRetryableStatus(status) {
692
+ return status === 408 || status === 429 || status >= 500;
693
+ }
694
+ /**
695
+ * Makes a receiver-supplied string safe to store: PostgreSQL `text` rejects
696
+ * NUL, and a write that always fails would leave the row claimed and resent
697
+ * forever instead of counting towards `maxAttempts`.
698
+ */
699
+ function truncate(value) {
700
+ const storable = value.split('\u0000').join('');
701
+ return storable.length > MAX_ERROR_DETAIL_LENGTH
702
+ ? storable.slice(0, MAX_ERROR_DETAIL_LENGTH)
703
+ : storable;
704
+ }
705
+ //# sourceMappingURL=workflow-webhook-delivery.service.js.map