@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
@@ -17,7 +17,8 @@ import type {
17
17
  PresignedUrlOptions,
18
18
  PresignedUploadResult,
19
19
  TimeEntry,
20
- WebhookEvent,
20
+ Webhook,
21
+ PublicWebhook,
21
22
  Workflow,
22
23
  CreateTaskInput,
23
24
  CreateProjectInput,
@@ -82,9 +83,12 @@ import {
82
83
  AttachmentDeletedEvent
83
84
  } from '../domain/events.js';
84
85
  import { validateAttachmentUrl, AttachmentValidationError } from '../domain/entities.js';
85
- import { validateCustomFieldValues } from '../domain/custom-fields.js';
86
+ import { validateCustomFieldValues, validateCustomFieldDefinitions } from '../domain/custom-fields.js';
86
87
  import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
87
88
  import { ValidationError, NotFoundError, ForbiddenError } from '../domain/errors.js';
89
+ import { WebhookDispatcher, assertWebhookUrl } from '../webhooks/dispatcher.js';
90
+ import { generateWebhookSecret } from '../webhooks/signature.js';
91
+ import type { DomainEvent } from '../domain/events.js';
88
92
  import { calculateCPM, type CPMOptions } from '../domain/cpm.js';
89
93
  import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
90
94
  import {
@@ -100,6 +104,8 @@ export class CriticalPathEngine {
100
104
  public readonly fileStorage?: FileStorageAdapter;
101
105
  public readonly plugins: PluginRegistry;
102
106
  public readonly events: DomainEventBus;
107
+ /** Delivers domain events to registered webhooks. Exposed for custom queues (`deliver`) and tests (`idle`). */
108
+ public readonly webhooks: WebhookDispatcher;
103
109
  public readonly ready: Promise<void> = Promise.resolve();
104
110
  /** The identity mutations are attributed to. Set only on views returned by `withActor`. */
105
111
  public readonly actor?: Actor;
@@ -115,6 +121,14 @@ export class CriticalPathEngine {
115
121
  this.fileStorage = config.fileStorage;
116
122
  this.plugins = new PluginRegistry();
117
123
  this.events = new DomainEventBus();
124
+ this.webhooks = new WebhookDispatcher(
125
+ {
126
+ listWebhooks: () => this.listAllWebhooks(),
127
+ resolveTenant: (event) => this.resolveEventTenant(event)
128
+ },
129
+ config.webhookDelivery
130
+ );
131
+ this.events.subscribe('*', (event) => this.webhooks.handle(event));
118
132
 
119
133
  if (config.plugins) {
120
134
  for (const plugin of config.plugins) {
@@ -122,9 +136,9 @@ export class CriticalPathEngine {
122
136
  }
123
137
  }
124
138
 
125
- if (config.initialData) {
126
- this.ready = this.seedInitialData(config.initialData);
127
- }
139
+ this.ready = this.initialize(config);
140
+ // Callers that never await `ready` should not crash on an unhandled rejection; awaiting it still throws.
141
+ this.ready.catch(() => {});
128
142
  }
129
143
 
130
144
  /**
@@ -250,6 +264,15 @@ export class CriticalPathEngine {
250
264
  return rest as T;
251
265
  }
252
266
 
267
+ private async initialize(config: CriticalPathConfig): Promise<void> {
268
+ if (config.initialData) {
269
+ await this.seedInitialData(config.initialData);
270
+ }
271
+ for (const plugin of this.plugins.getPlugins()) {
272
+ await plugin.init?.(this);
273
+ }
274
+ }
275
+
253
276
  private async seedInitialData(data: NonNullable<CriticalPathConfig['initialData']>): Promise<void> {
254
277
  if (data.workflows) {
255
278
  for (const wf of data.workflows) {
@@ -319,7 +342,6 @@ export class CriticalPathEngine {
319
342
  action: 'workflow.created',
320
343
  details: { name: created.name }
321
344
  });
322
- this.dispatchWebhook('workflow.created', { workflow: created });
323
345
  return created;
324
346
  }
325
347
 
@@ -347,7 +369,6 @@ export class CriticalPathEngine {
347
369
  action: 'workflow.updated',
348
370
  details: { name: updated.name }
349
371
  });
350
- this.dispatchWebhook('workflow.updated', { workflow: updated, previous: existing });
351
372
  }
352
373
  return updated;
353
374
  }
@@ -366,7 +387,7 @@ export class CriticalPathEngine {
366
387
  aggregateId: id,
367
388
  aggregateType: 'Workflow',
368
389
  occurredAt: now,
369
- payload: { workflowId: id, name: existing.name }
390
+ payload: { workflowId: id, name: existing.name, tenantId: existing.tenantId }
370
391
  };
371
392
  await this.events.publish(event);
372
393
 
@@ -375,7 +396,6 @@ export class CriticalPathEngine {
375
396
  action: 'workflow.deleted',
376
397
  details: { name: existing.name }
377
398
  });
378
- this.dispatchWebhook('workflow.deleted', { workflowId: id, name: existing.name });
379
399
  }
380
400
  return deleted;
381
401
  }
@@ -431,6 +451,7 @@ export class CriticalPathEngine {
431
451
 
432
452
  async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
433
453
  await this.requireWorkspaceAccess('project.create');
454
+ validateCustomFieldDefinitions(project.customFieldDefinitions, this.plugins.getCustomFieldTypes());
434
455
  // Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
435
456
  const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
436
457
  // The creator administers the project they create.
@@ -456,7 +477,6 @@ export class CriticalPathEngine {
456
477
  action: 'project.created',
457
478
  details: { name: created.name, key: created.key }
458
479
  });
459
- this.dispatchWebhook('project.created', { project: created });
460
480
  return created;
461
481
  }
462
482
 
@@ -465,6 +485,7 @@ export class CriticalPathEngine {
465
485
  if (!existing) return null;
466
486
  await this.requireProjectAccess('project.update', id);
467
487
  if ('members' in updates) await this.requireProjectAccess('project.manage_members', id);
488
+ validateCustomFieldDefinitions(updates.customFieldDefinitions, this.plugins.getCustomFieldTypes());
468
489
  updates = this.withoutTenant(updates);
469
490
 
470
491
  const updated = await this.store.updateProject(id, updates);
@@ -486,7 +507,6 @@ export class CriticalPathEngine {
486
507
  action: 'project.updated',
487
508
  details: { name: updated.name }
488
509
  });
489
- this.dispatchWebhook('project.updated', { project: updated });
490
510
  }
491
511
  return updated;
492
512
  }
@@ -519,7 +539,7 @@ export class CriticalPathEngine {
519
539
  aggregateId: id,
520
540
  aggregateType: 'Project',
521
541
  occurredAt: now,
522
- payload: { projectId: id, name: existing.name, deletedTaskIds }
542
+ payload: { projectId: id, name: existing.name, deletedTaskIds, tenantId: existing.tenantId }
523
543
  };
524
544
  await this.events.publish(event);
525
545
 
@@ -529,7 +549,6 @@ export class CriticalPathEngine {
529
549
  action: 'project.deleted',
530
550
  details: { name: existing.name, deletedTaskCount: deletedTaskIds.length }
531
551
  });
532
- this.dispatchWebhook('project.deleted', { projectId: id, name: existing.name, deletedTaskIds });
533
552
  }
534
553
  return deleted;
535
554
  }
@@ -553,17 +572,19 @@ export class CriticalPathEngine {
553
572
  taskInput = { ...taskInput, reporterId: this.actor.userId };
554
573
  }
555
574
  const processedInput = await this.plugins.runBeforeTaskCreate(taskInput);
556
- const projectId = processedInput.projectId || taskInput.projectId;
575
+ if (processedInput.projectId && processedInput.projectId !== taskInput.projectId) {
576
+ throw new ValidationError('Plugins cannot move a task to another project in beforeTaskCreate.');
577
+ }
578
+ const projectId = taskInput.projectId;
557
579
  const project = await this.store.getProject(projectId);
558
580
  const workflow = await this.resolveProjectWorkflow(projectId);
559
581
 
560
- // Validate custom field domain invariants
561
- if (project?.customFieldDefinitions && (processedInput.customFields || taskInput.customFields)) {
562
- validateCustomFieldValues(
563
- project.customFieldDefinitions,
564
- processedInput.customFields || taskInput.customFields
565
- );
566
- }
582
+ // Validate custom fields on the final (post-plugin) input, including required fields when none are given
583
+ validateCustomFieldValues(
584
+ project?.customFieldDefinitions,
585
+ processedInput.customFields ?? taskInput.customFields,
586
+ this.plugins.getCustomFieldTypes()
587
+ );
567
588
 
568
589
  const defaultStatus = workflow?.defaultStatusKey || 'todo';
569
590
  const initialStatus = processedInput.status || taskInput.status || defaultStatus;
@@ -642,7 +663,6 @@ export class CriticalPathEngine {
642
663
  details: { title: created.title, status: created.status }
643
664
  });
644
665
 
645
- this.dispatchWebhook('task.created', { task: created });
646
666
  return created;
647
667
  }
648
668
 
@@ -673,24 +693,32 @@ export class CriticalPathEngine {
673
693
  const project = await this.store.getProject(existing.projectId);
674
694
  const workflow = await this.resolveProjectWorkflow(existing.projectId);
675
695
 
696
+ // Plugins run first; their output is then validated like caller input. Identity and
697
+ // ownership fields are never changed by hooks.
698
+ const {
699
+ id: _id,
700
+ projectId: _projectId,
701
+ createdAt: _createdAt,
702
+ ...processedUpdates
703
+ } = await this.plugins.runBeforeTaskUpdate(id, taskUpdates) as Partial<Task>;
704
+
676
705
  // Validate workflow transition invariant
677
- if (taskUpdates.status && taskUpdates.status !== existing.status) {
678
- const isValid = validateTransition(workflow || undefined, existing.status, taskUpdates.status);
706
+ if (processedUpdates.status && processedUpdates.status !== existing.status) {
707
+ const isValid = validateTransition(workflow || undefined, existing.status, processedUpdates.status);
679
708
  if (!isValid) {
680
- throw new WorkflowValidationError(existing.status, taskUpdates.status, workflow?.id);
709
+ throw new WorkflowValidationError(existing.status, processedUpdates.status, workflow?.id);
681
710
  }
682
711
  }
683
712
 
684
713
  // Validate custom field invariants
685
- if (project?.customFieldDefinitions && taskUpdates.customFields) {
686
- validateCustomFieldValues(project.customFieldDefinitions, {
687
- ...existing.customFields,
688
- ...taskUpdates.customFields
689
- });
714
+ if (processedUpdates.customFields) {
715
+ validateCustomFieldValues(
716
+ project?.customFieldDefinitions,
717
+ { ...existing.customFields, ...processedUpdates.customFields },
718
+ this.plugins.getCustomFieldTypes()
719
+ );
690
720
  }
691
721
 
692
- const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
693
-
694
722
  const now = new Date().toISOString();
695
723
  const existingStatusDef = resolveStatusDefinition(
696
724
  existing.status,
@@ -925,10 +953,6 @@ export class CriticalPathEngine {
925
953
  action: 'task.blocked',
926
954
  details: { reason: blockedReason, ...(actorName ? { actorName } : {}) }
927
955
  });
928
- this.dispatchWebhook('task.blocked', {
929
- task: updated,
930
- reason: blockedReason
931
- });
932
956
  } else if (wasBlocked && !isNowBlocked) {
933
957
  const unblockedEvent: TaskUnblockedEvent = {
934
958
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -951,9 +975,6 @@ export class CriticalPathEngine {
951
975
  action: 'task.unblocked',
952
976
  details: { ...(actorName ? { actorName } : {}) }
953
977
  });
954
- this.dispatchWebhook('task.unblocked', {
955
- task: updated
956
- });
957
978
  }
958
979
 
959
980
  await this.store.logActivity({
@@ -968,10 +989,6 @@ export class CriticalPathEngine {
968
989
  }
969
990
  });
970
991
 
971
- this.dispatchWebhook(isStatusChange ? 'task.status_changed' : 'task.updated', {
972
- task: updated,
973
- previous: existing
974
- });
975
992
 
976
993
  return updated;
977
994
  }
@@ -1042,10 +1059,6 @@ export class CriticalPathEngine {
1042
1059
  details: { unblockedByTaskId: completedTask.id }
1043
1060
  });
1044
1061
 
1045
- this.dispatchWebhook('task.unblocked', {
1046
- task: updatedDownstream,
1047
- upstreamTaskId: completedTask.id
1048
- });
1049
1062
  }
1050
1063
  }
1051
1064
  } catch (err) {
@@ -1058,11 +1071,11 @@ export class CriticalPathEngine {
1058
1071
  if (!existing) return false;
1059
1072
  await this.requireProjectAccess('task.delete', existing.projectId);
1060
1073
 
1061
- await this.plugins.runBeforeTaskDelete(id);
1074
+ await this.plugins.runBeforeTaskDelete(id, existing);
1062
1075
  const deleted = await this.store.deleteTask(id);
1063
1076
 
1064
1077
  if (deleted) {
1065
- await this.plugins.runAfterTaskDelete(id);
1078
+ await this.plugins.runAfterTaskDelete(id, existing);
1066
1079
  const now = new Date().toISOString();
1067
1080
 
1068
1081
  const event: TaskDeletedEvent = {
@@ -1086,7 +1099,6 @@ export class CriticalPathEngine {
1086
1099
  action: 'task.deleted',
1087
1100
  details: { title: existing.title }
1088
1101
  });
1089
- this.dispatchWebhook('task.deleted', { taskId: id, title: existing.title });
1090
1102
  }
1091
1103
  return deleted;
1092
1104
  }
@@ -1297,7 +1309,6 @@ export class CriticalPathEngine {
1297
1309
  };
1298
1310
  await this.events.publish(event);
1299
1311
 
1300
- this.dispatchWebhook('comment.created', { comment: created });
1301
1312
  return created;
1302
1313
  }
1303
1314
 
@@ -1326,7 +1337,6 @@ export class CriticalPathEngine {
1326
1337
  };
1327
1338
  await this.events.publish(event);
1328
1339
 
1329
- this.dispatchWebhook('comment.updated', { comment: updated });
1330
1340
  return updated;
1331
1341
  }
1332
1342
 
@@ -1351,7 +1361,6 @@ export class CriticalPathEngine {
1351
1361
  };
1352
1362
  await this.events.publish(event);
1353
1363
 
1354
- this.dispatchWebhook('comment.deleted', { commentId: id, taskId: existing.taskId });
1355
1364
  }
1356
1365
  return deleted;
1357
1366
  }
@@ -1399,7 +1408,6 @@ export class CriticalPathEngine {
1399
1408
  };
1400
1409
  await this.events.publish(event);
1401
1410
 
1402
- this.dispatchWebhook('comment.reaction.added', { comment: updated, reaction });
1403
1411
  return updated;
1404
1412
  }
1405
1413
 
@@ -1432,7 +1440,6 @@ export class CriticalPathEngine {
1432
1440
  };
1433
1441
  await this.events.publish(event);
1434
1442
 
1435
- this.dispatchWebhook('comment.reaction.removed', { comment: updated, reaction });
1436
1443
  return updated;
1437
1444
  }
1438
1445
 
@@ -1481,7 +1488,6 @@ export class CriticalPathEngine {
1481
1488
  };
1482
1489
  await this.events.publish(event);
1483
1490
 
1484
- this.dispatchWebhook('attachment.created', { attachment: created });
1485
1491
  return created;
1486
1492
  }
1487
1493
 
@@ -1510,11 +1516,15 @@ export class CriticalPathEngine {
1510
1516
  aggregateId: id,
1511
1517
  aggregateType: 'Attachment',
1512
1518
  occurredAt: now,
1513
- payload: { attachmentId: id, storageKey: existing.storageKey, url: existing.url }
1519
+ payload: {
1520
+ attachmentId: id,
1521
+ storageKey: existing.storageKey,
1522
+ url: existing.url,
1523
+ projectId: await this.projectIdOfAttachment(existing)
1524
+ }
1514
1525
  };
1515
1526
  await this.events.publish(event);
1516
1527
 
1517
- this.dispatchWebhook('attachment.deleted', { attachmentId: id, taskId: existing.taskId, projectId: existing.projectId });
1518
1528
  }
1519
1529
  return deleted;
1520
1530
  }
@@ -1639,7 +1649,6 @@ export class CriticalPathEngine {
1639
1649
  };
1640
1650
  await this.events.publish(event);
1641
1651
 
1642
- this.dispatchWebhook('team.created', { team: created });
1643
1652
  return created;
1644
1653
  }
1645
1654
 
@@ -1682,7 +1691,6 @@ export class CriticalPathEngine {
1682
1691
  };
1683
1692
  await this.events.publish(event);
1684
1693
 
1685
- this.dispatchWebhook('container.created', { container: created });
1686
1694
  return created;
1687
1695
  }
1688
1696
 
@@ -1716,7 +1724,7 @@ export class CriticalPathEngine {
1716
1724
  await this.requireProjectAccess('plan.manage', input.projectId);
1717
1725
  const project = await this.store.getProject(input.projectId);
1718
1726
  if (project?.customFieldDefinitions && input.customFields) {
1719
- validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
1727
+ validateCustomFieldValues(project.customFieldDefinitions, input.customFields, this.plugins.getCustomFieldTypes());
1720
1728
  }
1721
1729
 
1722
1730
  const created = await this.store.createDeliverable({
@@ -1743,7 +1751,6 @@ export class CriticalPathEngine {
1743
1751
  action: 'deliverable.created',
1744
1752
  details: { title: created.title, format: created.format }
1745
1753
  });
1746
- this.dispatchWebhook('deliverable.created', { deliverable: created });
1747
1754
  return created;
1748
1755
  }
1749
1756
 
@@ -1774,11 +1781,6 @@ export class CriticalPathEngine {
1774
1781
  }
1775
1782
  };
1776
1783
  await this.events.publish(statusEvent);
1777
- this.dispatchWebhook('deliverable.status_changed', {
1778
- deliverable: updated,
1779
- previousStatus: existing.status,
1780
- newStatus: updated.status
1781
- });
1782
1784
  }
1783
1785
 
1784
1786
  const updateEvent: DeliverableUpdatedEvent = {
@@ -1797,7 +1799,6 @@ export class CriticalPathEngine {
1797
1799
  action: 'deliverable.updated',
1798
1800
  details: { title: updated.title, status: updated.status }
1799
1801
  });
1800
- this.dispatchWebhook('deliverable.updated', { deliverable: updated });
1801
1802
  }
1802
1803
  return updated;
1803
1804
  }
@@ -1826,7 +1827,6 @@ export class CriticalPathEngine {
1826
1827
  action: 'deliverable.deleted',
1827
1828
  details: { title: existing.title }
1828
1829
  });
1829
- this.dispatchWebhook('deliverable.deleted', { deliverableId: id, projectId: existing.projectId });
1830
1830
  }
1831
1831
  return deleted;
1832
1832
  }
@@ -1904,7 +1904,6 @@ export class CriticalPathEngine {
1904
1904
  payload: { iteration: created }
1905
1905
  };
1906
1906
  await this.events.publish(event);
1907
- this.dispatchWebhook('iteration.started', { iteration: created });
1908
1907
  }
1909
1908
  return created;
1910
1909
  }
@@ -1927,7 +1926,6 @@ export class CriticalPathEngine {
1927
1926
  payload: { iteration: updated }
1928
1927
  };
1929
1928
  await this.events.publish(event);
1930
- this.dispatchWebhook('iteration.started', { iteration: updated });
1931
1929
  } else if (existing.status !== 'completed' && updated.status === 'completed') {
1932
1930
  const event: IterationCompletedEvent = {
1933
1931
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -1938,7 +1936,6 @@ export class CriticalPathEngine {
1938
1936
  payload: { iteration: updated }
1939
1937
  };
1940
1938
  await this.events.publish(event);
1941
- this.dispatchWebhook('iteration.completed', { iteration: updated });
1942
1939
  }
1943
1940
  }
1944
1941
  return updated;
@@ -2123,19 +2120,108 @@ export class CriticalPathEngine {
2123
2120
  );
2124
2121
  }
2125
2122
 
2126
- private async dispatchWebhook(event: WebhookEvent, payload: Record<string, unknown>): Promise<void> {
2123
+ // --- Webhooks ---
2124
+
2125
+ /** Webhooks from `config.webhooks` (static, not editable) plus those in the store. */
2126
+ private async listAllWebhooks(): Promise<Webhook[]> {
2127
+ const fromConfig: Webhook[] = (this.config.webhooks ?? []).map((w, i) => ({
2128
+ ...w,
2129
+ id: `config_${i}`,
2130
+ createdAt: new Date(0).toISOString()
2131
+ }));
2132
+ return [...fromConfig, ...(await this.store.getWebhooks())];
2133
+ }
2134
+
2135
+ private toPublicWebhook({ secret, ...webhook }: Webhook): PublicWebhook {
2136
+ return { ...webhook, hasSecret: !!secret };
2137
+ }
2138
+
2139
+ /** Webhooks in the actor's tenant, with secrets redacted. Requires `workspace.manage` on views. */
2140
+ async getWebhooks(): Promise<PublicWebhook[]> {
2141
+ await this.requireWorkspaceAccess('workspace.manage');
2127
2142
  const webhooks = await this.store.getWebhooks();
2128
- const active = webhooks.filter((w) => w.active && w.events.includes(event));
2129
-
2130
- for (const wh of active) {
2131
- fetch(wh.url, {
2132
- method: 'POST',
2133
- headers: { 'Content-Type': 'application/json' },
2134
- body: JSON.stringify({ event, timestamp: new Date().toISOString(), data: payload })
2135
- }).catch(() => {
2136
- // Silent catch for webhook errors in dev
2137
- });
2143
+ return webhooks.filter((w) => this.inActorTenant(w)).map((w) => this.toPublicWebhook(w));
2144
+ }
2145
+
2146
+ async getWebhook(id: string): Promise<PublicWebhook | null> {
2147
+ await this.requireWorkspaceAccess('workspace.manage');
2148
+ const webhook = await this.store.getWebhook(id);
2149
+ return webhook && this.inActorTenant(webhook) ? this.toPublicWebhook(webhook) : null;
2150
+ }
2151
+
2152
+ /**
2153
+ * Registers a webhook. The signing secret is returned only here; generate one by omitting
2154
+ * `secret`. Deliveries carry `X-CriticalPath-Signature` (see `verifyWebhookSignature`).
2155
+ */
2156
+ async createWebhook(input: {
2157
+ name: string;
2158
+ url: string;
2159
+ events: Webhook['events'];
2160
+ secret?: string;
2161
+ active?: boolean;
2162
+ }): Promise<{ webhook: PublicWebhook; secret: string }> {
2163
+ await this.requireWorkspaceAccess('workspace.manage');
2164
+ this.validateWebhookUrl(input.url);
2165
+ const secret = input.secret ?? generateWebhookSecret();
2166
+ const created = await this.store.addWebhook({
2167
+ name: input.name,
2168
+ url: input.url,
2169
+ events: input.events,
2170
+ secret,
2171
+ active: input.active ?? true,
2172
+ tenantId: this.actor?.tenantId
2173
+ });
2174
+ this.webhooks.invalidate();
2175
+ return { webhook: this.toPublicWebhook(created), secret };
2176
+ }
2177
+
2178
+ async updateWebhook(
2179
+ id: string,
2180
+ updates: Partial<Pick<Webhook, 'name' | 'url' | 'events' | 'active' | 'secret'>>
2181
+ ): Promise<PublicWebhook | null> {
2182
+ if (!(await this.getWebhook(id))) return null;
2183
+ if (updates.url) this.validateWebhookUrl(updates.url);
2184
+ const { name, url, events, active, secret } = updates;
2185
+ const updated = await this.store.updateWebhook(
2186
+ id,
2187
+ Object.fromEntries(Object.entries({ name, url, events, active, secret }).filter(([, v]) => v !== undefined))
2188
+ );
2189
+ this.webhooks.invalidate();
2190
+ return updated ? this.toPublicWebhook(updated) : null;
2191
+ }
2192
+
2193
+ async deleteWebhook(id: string): Promise<boolean> {
2194
+ if (!(await this.getWebhook(id))) return false;
2195
+ const deleted = await this.store.deleteWebhook(id);
2196
+ this.webhooks.invalidate();
2197
+ return deleted;
2198
+ }
2199
+
2200
+ private validateWebhookUrl(url: string): void {
2201
+ try {
2202
+ assertWebhookUrl(url, this.config.webhookDelivery?.allowPrivateUrls);
2203
+ } catch (err) {
2204
+ throw new ValidationError((err as Error).message);
2138
2205
  }
2139
2206
  }
2140
- }
2141
2207
 
2208
+ /** Finds the tenant an event belongs to, from the entity in its payload or its project. */
2209
+ private async resolveEventTenant(event: DomainEvent): Promise<string | undefined> {
2210
+ const p = event.payload as Record<string, any>;
2211
+ const owner = p.project ?? p.workflow ?? p.team;
2212
+ if (owner) return owner.tenantId ?? undefined;
2213
+ // Deleted projects and workflows carry their tenant in the payload.
2214
+ if ('tenantId' in p) return p.tenantId ?? undefined;
2215
+
2216
+ const projectId: string | undefined =
2217
+ p.projectId ??
2218
+ p.task?.projectId ??
2219
+ p.deliverable?.projectId ??
2220
+ p.iteration?.projectId ??
2221
+ p.container?.projectId ??
2222
+ p.attachment?.projectId ??
2223
+ (await this.projectIdOfTask(p.taskId ?? p.comment?.taskId ?? p.attachment?.taskId ?? p.dependency?.taskId ?? p.timeEntry?.taskId));
2224
+ if (!projectId) return undefined;
2225
+ return (await this.store.getProject(projectId))?.tenantId ?? undefined;
2226
+ }
2227
+ }
package/src/index.ts CHANGED
@@ -5,4 +5,5 @@ export * from './storage/index.js';
5
5
  export * from './plugins/index.js';
6
6
  export * from './engine/index.js';
7
7
  export * from './domain/index.js';
8
+ export * from './webhooks/index.js';
8
9
 
@@ -1,12 +1,31 @@
1
- import type { CriticalPathPlugin, Task } from '../types/index.js';
1
+ import type { CriticalPathPlugin, CustomFieldType, PluginMiddleware, PluginRoute, Task } from '../types/index.js';
2
+
3
+ export const BUILT_IN_CUSTOM_FIELD_TYPES = [
4
+ 'text',
5
+ 'number',
6
+ 'date',
7
+ 'boolean',
8
+ 'single_select',
9
+ 'multi_select',
10
+ 'user'
11
+ ] as const;
2
12
 
3
13
  export class PluginRegistry {
4
14
  private plugins = new Map<string, CriticalPathPlugin>();
15
+ private fieldTypes = new Map<string, CustomFieldType>();
5
16
 
6
17
  register(plugin: CriticalPathPlugin): void {
7
18
  if (this.plugins.has(plugin.id)) {
8
19
  throw new Error(`Plugin with ID "${plugin.id}" is already registered.`);
9
20
  }
21
+ for (const fieldType of plugin.customFieldTypes ?? []) {
22
+ if ((BUILT_IN_CUSTOM_FIELD_TYPES as readonly string[]).includes(fieldType.type) || this.fieldTypes.has(fieldType.type)) {
23
+ throw new Error(`Plugin "${plugin.id}" registers custom field type "${fieldType.type}", which already exists.`);
24
+ }
25
+ }
26
+ for (const fieldType of plugin.customFieldTypes ?? []) {
27
+ this.fieldTypes.set(fieldType.type, fieldType);
28
+ }
10
29
  this.plugins.set(plugin.id, plugin);
11
30
  }
12
31
 
@@ -14,6 +33,19 @@ export class PluginRegistry {
14
33
  return Array.from(this.plugins.values());
15
34
  }
16
35
 
36
+ /** Custom field types contributed by plugins, keyed by `type`. */
37
+ getCustomFieldTypes(): ReadonlyMap<string, CustomFieldType> {
38
+ return this.fieldTypes;
39
+ }
40
+
41
+ getRoutes(): Array<PluginRoute & { pluginId: string }> {
42
+ return this.getPlugins().flatMap((p) => (p.routes ?? []).map((route) => ({ ...route, pluginId: p.id })));
43
+ }
44
+
45
+ getMiddleware(): PluginMiddleware[] {
46
+ return this.getPlugins().flatMap((p) => (p.middleware ? [p.middleware] : []));
47
+ }
48
+
17
49
  async runBeforeTaskCreate(task: Partial<Task>): Promise<Partial<Task>> {
18
50
  let currentTask = { ...task };
19
51
  for (const plugin of this.plugins.values()) {
@@ -25,11 +57,7 @@ export class PluginRegistry {
25
57
  }
26
58
 
27
59
  async runAfterTaskCreate(task: Task): Promise<void> {
28
- for (const plugin of this.plugins.values()) {
29
- if (plugin.hooks?.afterTaskCreate) {
30
- await plugin.hooks.afterTaskCreate(task);
31
- }
32
- }
60
+ await this.runAfter('afterTaskCreate', (hooks) => hooks.afterTaskCreate?.(task));
33
61
  }
34
62
 
35
63
  async runBeforeTaskUpdate(id: string, updates: Partial<Task>): Promise<Partial<Task>> {
@@ -43,25 +71,32 @@ export class PluginRegistry {
43
71
  }
44
72
 
45
73
  async runAfterTaskUpdate(task: Task, previousState: Task): Promise<void> {
46
- for (const plugin of this.plugins.values()) {
47
- if (plugin.hooks?.afterTaskUpdate) {
48
- await plugin.hooks.afterTaskUpdate(task, previousState);
49
- }
50
- }
74
+ await this.runAfter('afterTaskUpdate', (hooks) => hooks.afterTaskUpdate?.(task, previousState));
51
75
  }
52
76
 
53
- async runBeforeTaskDelete(id: string): Promise<void> {
77
+ async runBeforeTaskDelete(id: string, task: Task): Promise<void> {
54
78
  for (const plugin of this.plugins.values()) {
55
79
  if (plugin.hooks?.beforeTaskDelete) {
56
- await plugin.hooks.beforeTaskDelete(id);
80
+ await plugin.hooks.beforeTaskDelete(id, task);
57
81
  }
58
82
  }
59
83
  }
60
84
 
61
- async runAfterTaskDelete(id: string): Promise<void> {
85
+ async runAfterTaskDelete(id: string, task: Task): Promise<void> {
86
+ await this.runAfter('afterTaskDelete', (hooks) => hooks.afterTaskDelete?.(id, task));
87
+ }
88
+
89
+ /** Runs an after-hook on every plugin; the change is already stored, so errors are only logged. */
90
+ private async runAfter(
91
+ name: string,
92
+ call: (hooks: NonNullable<CriticalPathPlugin['hooks']>) => unknown
93
+ ): Promise<void> {
62
94
  for (const plugin of this.plugins.values()) {
63
- if (plugin.hooks?.afterTaskDelete) {
64
- await plugin.hooks.afterTaskDelete(id);
95
+ if (!plugin.hooks) continue;
96
+ try {
97
+ await call(plugin.hooks);
98
+ } catch (err) {
99
+ console.error(`[CriticalPath] Plugin "${plugin.id}" ${name} hook failed:`, err);
65
100
  }
66
101
  }
67
102
  }