@critical-path/core 0.26.0 → 0.28.0

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 (83) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +15 -0
  3. package/dist/domain/custom-fields.d.ts +7 -2
  4. package/dist/domain/custom-fields.d.ts.map +1 -1
  5. package/dist/domain/custom-fields.js +23 -1
  6. package/dist/domain/custom-fields.js.map +1 -1
  7. package/dist/domain/events.d.ts +5 -0
  8. package/dist/domain/events.d.ts.map +1 -1
  9. package/dist/domain/events.js +34 -0
  10. package/dist/domain/events.js.map +1 -1
  11. package/dist/engine/index.d.ts +30 -2
  12. package/dist/engine/index.d.ts.map +1 -1
  13. package/dist/engine/index.js +133 -77
  14. package/dist/engine/index.js.map +1 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/plugins/index.d.ts +13 -3
  20. package/dist/plugins/index.d.ts.map +1 -1
  21. package/dist/plugins/index.js +44 -15
  22. package/dist/plugins/index.js.map +1 -1
  23. package/dist/plugins/plugins.test.d.ts +2 -0
  24. package/dist/plugins/plugins.test.d.ts.map +1 -0
  25. package/dist/plugins/plugins.test.js +137 -0
  26. package/dist/plugins/plugins.test.js.map +1 -0
  27. package/dist/schemas/index.d.ts +167 -121
  28. package/dist/schemas/index.d.ts.map +1 -1
  29. package/dist/schemas/index.js +15 -1
  30. package/dist/schemas/index.js.map +1 -1
  31. package/dist/schemas/schemas.test.js +1 -0
  32. package/dist/schemas/schemas.test.js.map +1 -1
  33. package/dist/store/firebase.d.ts +3 -0
  34. package/dist/store/firebase.d.ts.map +1 -1
  35. package/dist/store/firebase.js +21 -0
  36. package/dist/store/firebase.js.map +1 -1
  37. package/dist/store/firebase.test.js +17 -0
  38. package/dist/store/firebase.test.js.map +1 -1
  39. package/dist/store/index.d.ts +6 -0
  40. package/dist/store/index.d.ts.map +1 -1
  41. package/dist/store/index.js +14 -0
  42. package/dist/store/index.js.map +1 -1
  43. package/dist/store/sqlite.d.ts +4 -0
  44. package/dist/store/sqlite.d.ts.map +1 -1
  45. package/dist/store/sqlite.js +42 -8
  46. package/dist/store/sqlite.js.map +1 -1
  47. package/dist/types/index.d.ts +67 -6
  48. package/dist/types/index.d.ts.map +1 -1
  49. package/dist/webhooks/dispatcher.d.ts +80 -0
  50. package/dist/webhooks/dispatcher.d.ts.map +1 -0
  51. package/dist/webhooks/dispatcher.js +133 -0
  52. package/dist/webhooks/dispatcher.js.map +1 -0
  53. package/dist/webhooks/index.d.ts +3 -0
  54. package/dist/webhooks/index.d.ts.map +1 -0
  55. package/dist/webhooks/index.js +3 -0
  56. package/dist/webhooks/index.js.map +1 -0
  57. package/dist/webhooks/signature.d.ts +28 -0
  58. package/dist/webhooks/signature.d.ts.map +1 -0
  59. package/dist/webhooks/signature.js +42 -0
  60. package/dist/webhooks/signature.js.map +1 -0
  61. package/dist/webhooks/webhooks.test.d.ts +2 -0
  62. package/dist/webhooks/webhooks.test.d.ts.map +1 -0
  63. package/dist/webhooks/webhooks.test.js +153 -0
  64. package/dist/webhooks/webhooks.test.js.map +1 -0
  65. package/package.json +1 -1
  66. package/src/domain/custom-fields.ts +43 -2
  67. package/src/domain/events.ts +42 -3
  68. package/src/engine/index.ts +170 -84
  69. package/src/index.ts +1 -0
  70. package/src/plugins/index.ts +51 -16
  71. package/src/plugins/plugins.test.ts +161 -0
  72. package/src/schemas/index.ts +19 -1
  73. package/src/schemas/schemas.test.ts +6 -3
  74. package/src/store/firebase.test.ts +20 -0
  75. package/src/store/firebase.ts +22 -0
  76. package/src/store/index.ts +19 -0
  77. package/src/store/sqlite.ts +54 -7
  78. package/src/types/index.ts +72 -33
  79. package/src/webhooks/dispatcher.ts +190 -0
  80. package/src/webhooks/index.ts +2 -0
  81. package/src/webhooks/signature.ts +57 -0
  82. package/src/webhooks/webhooks.test.ts +200 -0
  83. package/tsconfig.tsbuildinfo +1 -1
@@ -5,9 +5,11 @@ import { validateTransition, getAllowedNextStatuses, getAllowedPreviousStatuses,
5
5
  import { extractMentions } from '../utils/mentions.js';
6
6
  import { DomainEventBus } from '../domain/events.js';
7
7
  import { validateAttachmentUrl } from '../domain/entities.js';
8
- import { validateCustomFieldValues } from '../domain/custom-fields.js';
8
+ import { validateCustomFieldValues, validateCustomFieldDefinitions } from '../domain/custom-fields.js';
9
9
  import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
10
10
  import { ValidationError, NotFoundError, ForbiddenError } from '../domain/errors.js';
11
+ import { WebhookDispatcher, assertWebhookUrl } from '../webhooks/dispatcher.js';
12
+ import { generateWebhookSecret } from '../webhooks/signature.js';
11
13
  import { calculateCPM } from '../domain/cpm.js';
12
14
  import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
13
15
  import { calculateTaskMetrics, reconstructTaskProgressHistory } from '../domain/metrics.js';
@@ -18,6 +20,8 @@ export class CriticalPathEngine {
18
20
  fileStorage;
19
21
  plugins;
20
22
  events;
23
+ /** Delivers domain events to registered webhooks. Exposed for custom queues (`deliver`) and tests (`idle`). */
24
+ webhooks;
21
25
  ready = Promise.resolve();
22
26
  /** The identity mutations are attributed to. Set only on views returned by `withActor`. */
23
27
  actor;
@@ -31,14 +35,19 @@ export class CriticalPathEngine {
31
35
  this.fileStorage = config.fileStorage;
32
36
  this.plugins = new PluginRegistry();
33
37
  this.events = new DomainEventBus();
38
+ this.webhooks = new WebhookDispatcher({
39
+ listWebhooks: () => this.listAllWebhooks(),
40
+ resolveTenant: (event) => this.resolveEventTenant(event)
41
+ }, config.webhookDelivery);
42
+ this.events.subscribe('*', (event) => this.webhooks.handle(event));
34
43
  if (config.plugins) {
35
44
  for (const plugin of config.plugins) {
36
45
  this.plugins.register(plugin);
37
46
  }
38
47
  }
39
- if (config.initialData) {
40
- this.ready = this.seedInitialData(config.initialData);
41
- }
48
+ this.ready = this.initialize(config);
49
+ // Callers that never await `ready` should not crash on an unhandled rejection; awaiting it still throws.
50
+ this.ready.catch(() => { });
42
51
  }
43
52
  /**
44
53
  * Returns a view of this engine that attributes every mutation to `actor`: activity log
@@ -151,6 +160,14 @@ export class CriticalPathEngine {
151
160
  const { tenantId: _ignored, ...rest } = updates;
152
161
  return rest;
153
162
  }
163
+ async initialize(config) {
164
+ if (config.initialData) {
165
+ await this.seedInitialData(config.initialData);
166
+ }
167
+ for (const plugin of this.plugins.getPlugins()) {
168
+ await plugin.init?.(this);
169
+ }
170
+ }
154
171
  async seedInitialData(data) {
155
172
  if (data.workflows) {
156
173
  for (const wf of data.workflows) {
@@ -215,7 +232,6 @@ export class CriticalPathEngine {
215
232
  action: 'workflow.created',
216
233
  details: { name: created.name }
217
234
  });
218
- this.dispatchWebhook('workflow.created', { workflow: created });
219
235
  return created;
220
236
  }
221
237
  async updateWorkflow(id, updates) {
@@ -241,7 +257,6 @@ export class CriticalPathEngine {
241
257
  action: 'workflow.updated',
242
258
  details: { name: updated.name }
243
259
  });
244
- this.dispatchWebhook('workflow.updated', { workflow: updated, previous: existing });
245
260
  }
246
261
  return updated;
247
262
  }
@@ -259,7 +274,7 @@ export class CriticalPathEngine {
259
274
  aggregateId: id,
260
275
  aggregateType: 'Workflow',
261
276
  occurredAt: now,
262
- payload: { workflowId: id, name: existing.name }
277
+ payload: { workflowId: id, name: existing.name, tenantId: existing.tenantId }
263
278
  };
264
279
  await this.events.publish(event);
265
280
  await this.store.logActivity({
@@ -267,7 +282,6 @@ export class CriticalPathEngine {
267
282
  action: 'workflow.deleted',
268
283
  details: { name: existing.name }
269
284
  });
270
- this.dispatchWebhook('workflow.deleted', { workflowId: id, name: existing.name });
271
285
  }
272
286
  return deleted;
273
287
  }
@@ -322,6 +336,7 @@ export class CriticalPathEngine {
322
336
  }
323
337
  async createProject(project) {
324
338
  await this.requireWorkspaceAccess('project.create');
339
+ validateCustomFieldDefinitions(project.customFieldDefinitions, this.plugins.getCustomFieldTypes());
325
340
  // Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
326
341
  const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
327
342
  // The creator administers the project they create.
@@ -345,7 +360,6 @@ export class CriticalPathEngine {
345
360
  action: 'project.created',
346
361
  details: { name: created.name, key: created.key }
347
362
  });
348
- this.dispatchWebhook('project.created', { project: created });
349
363
  return created;
350
364
  }
351
365
  async updateProject(id, updates) {
@@ -355,6 +369,7 @@ export class CriticalPathEngine {
355
369
  await this.requireProjectAccess('project.update', id);
356
370
  if ('members' in updates)
357
371
  await this.requireProjectAccess('project.manage_members', id);
372
+ validateCustomFieldDefinitions(updates.customFieldDefinitions, this.plugins.getCustomFieldTypes());
358
373
  updates = this.withoutTenant(updates);
359
374
  const updated = await this.store.updateProject(id, updates);
360
375
  if (updated) {
@@ -374,7 +389,6 @@ export class CriticalPathEngine {
374
389
  action: 'project.updated',
375
390
  details: { name: updated.name }
376
391
  });
377
- this.dispatchWebhook('project.updated', { project: updated });
378
392
  }
379
393
  return updated;
380
394
  }
@@ -405,7 +419,7 @@ export class CriticalPathEngine {
405
419
  aggregateId: id,
406
420
  aggregateType: 'Project',
407
421
  occurredAt: now,
408
- payload: { projectId: id, name: existing.name, deletedTaskIds }
422
+ payload: { projectId: id, name: existing.name, deletedTaskIds, tenantId: existing.tenantId }
409
423
  };
410
424
  await this.events.publish(event);
411
425
  await this.store.logActivity({
@@ -414,7 +428,6 @@ export class CriticalPathEngine {
414
428
  action: 'project.deleted',
415
429
  details: { name: existing.name, deletedTaskCount: deletedTaskIds.length }
416
430
  });
417
- this.dispatchWebhook('project.deleted', { projectId: id, name: existing.name, deletedTaskIds });
418
431
  }
419
432
  return deleted;
420
433
  }
@@ -437,13 +450,14 @@ export class CriticalPathEngine {
437
450
  taskInput = { ...taskInput, reporterId: this.actor.userId };
438
451
  }
439
452
  const processedInput = await this.plugins.runBeforeTaskCreate(taskInput);
440
- const projectId = processedInput.projectId || taskInput.projectId;
453
+ if (processedInput.projectId && processedInput.projectId !== taskInput.projectId) {
454
+ throw new ValidationError('Plugins cannot move a task to another project in beforeTaskCreate.');
455
+ }
456
+ const projectId = taskInput.projectId;
441
457
  const project = await this.store.getProject(projectId);
442
458
  const workflow = await this.resolveProjectWorkflow(projectId);
443
- // Validate custom field domain invariants
444
- if (project?.customFieldDefinitions && (processedInput.customFields || taskInput.customFields)) {
445
- validateCustomFieldValues(project.customFieldDefinitions, processedInput.customFields || taskInput.customFields);
446
- }
459
+ // Validate custom fields on the final (post-plugin) input, including required fields when none are given
460
+ validateCustomFieldValues(project?.customFieldDefinitions, processedInput.customFields ?? taskInput.customFields, this.plugins.getCustomFieldTypes());
447
461
  const defaultStatus = workflow?.defaultStatusKey || 'todo';
448
462
  const initialStatus = processedInput.status || taskInput.status || defaultStatus;
449
463
  const now = new Date().toISOString();
@@ -514,7 +528,6 @@ export class CriticalPathEngine {
514
528
  action: 'task.created',
515
529
  details: { title: created.title, status: created.status }
516
530
  });
517
- this.dispatchWebhook('task.created', { task: created });
518
531
  return created;
519
532
  }
520
533
  async updateTask(id, updates, options) {
@@ -531,21 +544,20 @@ export class CriticalPathEngine {
531
544
  const taskUpdates = updates;
532
545
  const project = await this.store.getProject(existing.projectId);
533
546
  const workflow = await this.resolveProjectWorkflow(existing.projectId);
547
+ // Plugins run first; their output is then validated like caller input. Identity and
548
+ // ownership fields are never changed by hooks.
549
+ const { id: _id, projectId: _projectId, createdAt: _createdAt, ...processedUpdates } = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
534
550
  // Validate workflow transition invariant
535
- if (taskUpdates.status && taskUpdates.status !== existing.status) {
536
- const isValid = validateTransition(workflow || undefined, existing.status, taskUpdates.status);
551
+ if (processedUpdates.status && processedUpdates.status !== existing.status) {
552
+ const isValid = validateTransition(workflow || undefined, existing.status, processedUpdates.status);
537
553
  if (!isValid) {
538
- throw new WorkflowValidationError(existing.status, taskUpdates.status, workflow?.id);
554
+ throw new WorkflowValidationError(existing.status, processedUpdates.status, workflow?.id);
539
555
  }
540
556
  }
541
557
  // Validate custom field invariants
542
- if (project?.customFieldDefinitions && taskUpdates.customFields) {
543
- validateCustomFieldValues(project.customFieldDefinitions, {
544
- ...existing.customFields,
545
- ...taskUpdates.customFields
546
- });
558
+ if (processedUpdates.customFields) {
559
+ validateCustomFieldValues(project?.customFieldDefinitions, { ...existing.customFields, ...processedUpdates.customFields }, this.plugins.getCustomFieldTypes());
547
560
  }
548
- const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
549
561
  const now = new Date().toISOString();
550
562
  const existingStatusDef = resolveStatusDefinition(existing.status, project?.statusDefinitions || workflow?.statuses);
551
563
  const newStatusDef = processedUpdates.status
@@ -759,10 +771,6 @@ export class CriticalPathEngine {
759
771
  action: 'task.blocked',
760
772
  details: { reason: blockedReason, ...(actorName ? { actorName } : {}) }
761
773
  });
762
- this.dispatchWebhook('task.blocked', {
763
- task: updated,
764
- reason: blockedReason
765
- });
766
774
  }
767
775
  else if (wasBlocked && !isNowBlocked) {
768
776
  const unblockedEvent = {
@@ -786,9 +794,6 @@ export class CriticalPathEngine {
786
794
  action: 'task.unblocked',
787
795
  details: { ...(actorName ? { actorName } : {}) }
788
796
  });
789
- this.dispatchWebhook('task.unblocked', {
790
- task: updated
791
- });
792
797
  }
793
798
  await this.store.logActivity({
794
799
  projectId: updated.projectId,
@@ -801,10 +806,6 @@ export class CriticalPathEngine {
801
806
  ...(actorName ? { actorName } : {})
802
807
  }
803
808
  });
804
- this.dispatchWebhook(isStatusChange ? 'task.status_changed' : 'task.updated', {
805
- task: updated,
806
- previous: existing
807
- });
808
809
  return updated;
809
810
  }
810
811
  async checkAndUnblockDownstreamTasks(completedTask) {
@@ -866,10 +867,6 @@ export class CriticalPathEngine {
866
867
  action: 'task.unblocked',
867
868
  details: { unblockedByTaskId: completedTask.id }
868
869
  });
869
- this.dispatchWebhook('task.unblocked', {
870
- task: updatedDownstream,
871
- upstreamTaskId: completedTask.id
872
- });
873
870
  }
874
871
  }
875
872
  }
@@ -882,10 +879,10 @@ export class CriticalPathEngine {
882
879
  if (!existing)
883
880
  return false;
884
881
  await this.requireProjectAccess('task.delete', existing.projectId);
885
- await this.plugins.runBeforeTaskDelete(id);
882
+ await this.plugins.runBeforeTaskDelete(id, existing);
886
883
  const deleted = await this.store.deleteTask(id);
887
884
  if (deleted) {
888
- await this.plugins.runAfterTaskDelete(id);
885
+ await this.plugins.runAfterTaskDelete(id, existing);
889
886
  const now = new Date().toISOString();
890
887
  const event = {
891
888
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -907,7 +904,6 @@ export class CriticalPathEngine {
907
904
  action: 'task.deleted',
908
905
  details: { title: existing.title }
909
906
  });
910
- this.dispatchWebhook('task.deleted', { taskId: id, title: existing.title });
911
907
  }
912
908
  return deleted;
913
909
  }
@@ -1090,7 +1086,6 @@ export class CriticalPathEngine {
1090
1086
  payload: { taskId: comment.taskId, comment: created }
1091
1087
  };
1092
1088
  await this.events.publish(event);
1093
- this.dispatchWebhook('comment.created', { comment: created });
1094
1089
  return created;
1095
1090
  }
1096
1091
  async updateComment(id, updates) {
@@ -1116,7 +1111,6 @@ export class CriticalPathEngine {
1116
1111
  payload: { comment: updated, previous: existing }
1117
1112
  };
1118
1113
  await this.events.publish(event);
1119
- this.dispatchWebhook('comment.updated', { comment: updated });
1120
1114
  return updated;
1121
1115
  }
1122
1116
  async deleteComment(id) {
@@ -1139,7 +1133,6 @@ export class CriticalPathEngine {
1139
1133
  payload: { taskId: existing.taskId, commentId: id }
1140
1134
  };
1141
1135
  await this.events.publish(event);
1142
- this.dispatchWebhook('comment.deleted', { commentId: id, taskId: existing.taskId });
1143
1136
  }
1144
1137
  return deleted;
1145
1138
  }
@@ -1185,7 +1178,6 @@ export class CriticalPathEngine {
1185
1178
  }
1186
1179
  };
1187
1180
  await this.events.publish(event);
1188
- this.dispatchWebhook('comment.reaction.added', { comment: updated, reaction });
1189
1181
  return updated;
1190
1182
  }
1191
1183
  async removeCommentReaction(commentId, input) {
@@ -1214,7 +1206,6 @@ export class CriticalPathEngine {
1214
1206
  payload: { commentId, reaction, comment: updated }
1215
1207
  };
1216
1208
  await this.events.publish(event);
1217
- this.dispatchWebhook('comment.reaction.removed', { comment: updated, reaction });
1218
1209
  return updated;
1219
1210
  }
1220
1211
  // --- Attachments ---
@@ -1256,7 +1247,6 @@ export class CriticalPathEngine {
1256
1247
  payload: { attachment: created }
1257
1248
  };
1258
1249
  await this.events.publish(event);
1259
- this.dispatchWebhook('attachment.created', { attachment: created });
1260
1250
  return created;
1261
1251
  }
1262
1252
  async deleteAttachment(id) {
@@ -1284,10 +1274,14 @@ export class CriticalPathEngine {
1284
1274
  aggregateId: id,
1285
1275
  aggregateType: 'Attachment',
1286
1276
  occurredAt: now,
1287
- payload: { attachmentId: id, storageKey: existing.storageKey, url: existing.url }
1277
+ payload: {
1278
+ attachmentId: id,
1279
+ storageKey: existing.storageKey,
1280
+ url: existing.url,
1281
+ projectId: await this.projectIdOfAttachment(existing)
1282
+ }
1288
1283
  };
1289
1284
  await this.events.publish(event);
1290
- this.dispatchWebhook('attachment.deleted', { attachmentId: id, taskId: existing.taskId, projectId: existing.projectId });
1291
1285
  }
1292
1286
  return deleted;
1293
1287
  }
@@ -1387,7 +1381,6 @@ export class CriticalPathEngine {
1387
1381
  payload: { team: created }
1388
1382
  };
1389
1383
  await this.events.publish(event);
1390
- this.dispatchWebhook('team.created', { team: created });
1391
1384
  return created;
1392
1385
  }
1393
1386
  async updateTeam(id, updates) {
@@ -1427,7 +1420,6 @@ export class CriticalPathEngine {
1427
1420
  payload: { container: created }
1428
1421
  };
1429
1422
  await this.events.publish(event);
1430
- this.dispatchWebhook('container.created', { container: created });
1431
1423
  return created;
1432
1424
  }
1433
1425
  async updateContainer(id, updates) {
@@ -1460,7 +1452,7 @@ export class CriticalPathEngine {
1460
1452
  await this.requireProjectAccess('plan.manage', input.projectId);
1461
1453
  const project = await this.store.getProject(input.projectId);
1462
1454
  if (project?.customFieldDefinitions && input.customFields) {
1463
- validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
1455
+ validateCustomFieldValues(project.customFieldDefinitions, input.customFields, this.plugins.getCustomFieldTypes());
1464
1456
  }
1465
1457
  const created = await this.store.createDeliverable({
1466
1458
  ...input,
@@ -1484,7 +1476,6 @@ export class CriticalPathEngine {
1484
1476
  action: 'deliverable.created',
1485
1477
  details: { title: created.title, format: created.format }
1486
1478
  });
1487
- this.dispatchWebhook('deliverable.created', { deliverable: created });
1488
1479
  return created;
1489
1480
  }
1490
1481
  async updateDeliverable(id, updates) {
@@ -1512,11 +1503,6 @@ export class CriticalPathEngine {
1512
1503
  }
1513
1504
  };
1514
1505
  await this.events.publish(statusEvent);
1515
- this.dispatchWebhook('deliverable.status_changed', {
1516
- deliverable: updated,
1517
- previousStatus: existing.status,
1518
- newStatus: updated.status
1519
- });
1520
1506
  }
1521
1507
  const updateEvent = {
1522
1508
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -1533,7 +1519,6 @@ export class CriticalPathEngine {
1533
1519
  action: 'deliverable.updated',
1534
1520
  details: { title: updated.title, status: updated.status }
1535
1521
  });
1536
- this.dispatchWebhook('deliverable.updated', { deliverable: updated });
1537
1522
  }
1538
1523
  return updated;
1539
1524
  }
@@ -1560,7 +1545,6 @@ export class CriticalPathEngine {
1560
1545
  action: 'deliverable.deleted',
1561
1546
  details: { title: existing.title }
1562
1547
  });
1563
- this.dispatchWebhook('deliverable.deleted', { deliverableId: id, projectId: existing.projectId });
1564
1548
  }
1565
1549
  return deleted;
1566
1550
  }
@@ -1634,7 +1618,6 @@ export class CriticalPathEngine {
1634
1618
  payload: { iteration: created }
1635
1619
  };
1636
1620
  await this.events.publish(event);
1637
- this.dispatchWebhook('iteration.started', { iteration: created });
1638
1621
  }
1639
1622
  return created;
1640
1623
  }
@@ -1656,7 +1639,6 @@ export class CriticalPathEngine {
1656
1639
  payload: { iteration: updated }
1657
1640
  };
1658
1641
  await this.events.publish(event);
1659
- this.dispatchWebhook('iteration.started', { iteration: updated });
1660
1642
  }
1661
1643
  else if (existing.status !== 'completed' && updated.status === 'completed') {
1662
1644
  const event = {
@@ -1668,7 +1650,6 @@ export class CriticalPathEngine {
1668
1650
  payload: { iteration: updated }
1669
1651
  };
1670
1652
  await this.events.publish(event);
1671
- this.dispatchWebhook('iteration.completed', { iteration: updated });
1672
1653
  }
1673
1654
  }
1674
1655
  return updated;
@@ -1819,18 +1800,93 @@ export class CriticalPathEngine {
1819
1800
  schedule
1820
1801
  });
1821
1802
  }
1822
- async dispatchWebhook(event, payload) {
1803
+ // --- Webhooks ---
1804
+ /** Webhooks from `config.webhooks` (static, not editable) plus those in the store. */
1805
+ async listAllWebhooks() {
1806
+ const fromConfig = (this.config.webhooks ?? []).map((w, i) => ({
1807
+ ...w,
1808
+ id: `config_${i}`,
1809
+ createdAt: new Date(0).toISOString()
1810
+ }));
1811
+ return [...fromConfig, ...(await this.store.getWebhooks())];
1812
+ }
1813
+ toPublicWebhook({ secret, ...webhook }) {
1814
+ return { ...webhook, hasSecret: !!secret };
1815
+ }
1816
+ /** Webhooks in the actor's tenant, with secrets redacted. Requires `workspace.manage` on views. */
1817
+ async getWebhooks() {
1818
+ await this.requireWorkspaceAccess('workspace.manage');
1823
1819
  const webhooks = await this.store.getWebhooks();
1824
- const active = webhooks.filter((w) => w.active && w.events.includes(event));
1825
- for (const wh of active) {
1826
- fetch(wh.url, {
1827
- method: 'POST',
1828
- headers: { 'Content-Type': 'application/json' },
1829
- body: JSON.stringify({ event, timestamp: new Date().toISOString(), data: payload })
1830
- }).catch(() => {
1831
- // Silent catch for webhook errors in dev
1832
- });
1820
+ return webhooks.filter((w) => this.inActorTenant(w)).map((w) => this.toPublicWebhook(w));
1821
+ }
1822
+ async getWebhook(id) {
1823
+ await this.requireWorkspaceAccess('workspace.manage');
1824
+ const webhook = await this.store.getWebhook(id);
1825
+ return webhook && this.inActorTenant(webhook) ? this.toPublicWebhook(webhook) : null;
1826
+ }
1827
+ /**
1828
+ * Registers a webhook. The signing secret is returned only here; generate one by omitting
1829
+ * `secret`. Deliveries carry `X-CriticalPath-Signature` (see `verifyWebhookSignature`).
1830
+ */
1831
+ async createWebhook(input) {
1832
+ await this.requireWorkspaceAccess('workspace.manage');
1833
+ this.validateWebhookUrl(input.url);
1834
+ const secret = input.secret ?? generateWebhookSecret();
1835
+ const created = await this.store.addWebhook({
1836
+ name: input.name,
1837
+ url: input.url,
1838
+ events: input.events,
1839
+ secret,
1840
+ active: input.active ?? true,
1841
+ tenantId: this.actor?.tenantId
1842
+ });
1843
+ this.webhooks.invalidate();
1844
+ return { webhook: this.toPublicWebhook(created), secret };
1845
+ }
1846
+ async updateWebhook(id, updates) {
1847
+ if (!(await this.getWebhook(id)))
1848
+ return null;
1849
+ if (updates.url)
1850
+ this.validateWebhookUrl(updates.url);
1851
+ const { name, url, events, active, secret } = updates;
1852
+ const updated = await this.store.updateWebhook(id, Object.fromEntries(Object.entries({ name, url, events, active, secret }).filter(([, v]) => v !== undefined)));
1853
+ this.webhooks.invalidate();
1854
+ return updated ? this.toPublicWebhook(updated) : null;
1855
+ }
1856
+ async deleteWebhook(id) {
1857
+ if (!(await this.getWebhook(id)))
1858
+ return false;
1859
+ const deleted = await this.store.deleteWebhook(id);
1860
+ this.webhooks.invalidate();
1861
+ return deleted;
1862
+ }
1863
+ validateWebhookUrl(url) {
1864
+ try {
1865
+ assertWebhookUrl(url, this.config.webhookDelivery?.allowPrivateUrls);
1833
1866
  }
1867
+ catch (err) {
1868
+ throw new ValidationError(err.message);
1869
+ }
1870
+ }
1871
+ /** Finds the tenant an event belongs to, from the entity in its payload or its project. */
1872
+ async resolveEventTenant(event) {
1873
+ const p = event.payload;
1874
+ const owner = p.project ?? p.workflow ?? p.team;
1875
+ if (owner)
1876
+ return owner.tenantId ?? undefined;
1877
+ // Deleted projects and workflows carry their tenant in the payload.
1878
+ if ('tenantId' in p)
1879
+ return p.tenantId ?? undefined;
1880
+ const projectId = p.projectId ??
1881
+ p.task?.projectId ??
1882
+ p.deliverable?.projectId ??
1883
+ p.iteration?.projectId ??
1884
+ p.container?.projectId ??
1885
+ p.attachment?.projectId ??
1886
+ (await this.projectIdOfTask(p.taskId ?? p.comment?.taskId ?? p.attachment?.taskId ?? p.dependency?.taskId ?? p.timeEntry?.taskId));
1887
+ if (!projectId)
1888
+ return undefined;
1889
+ return (await this.store.getProject(projectId))?.tenantId ?? undefined;
1834
1890
  }
1835
1891
  }
1836
1892
  //# sourceMappingURL=index.js.map