@critical-path/core 0.26.0 → 0.27.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.
- package/CHANGELOG.md +28 -0
- package/README.md +13 -0
- package/dist/domain/events.d.ts +5 -0
- package/dist/domain/events.d.ts.map +1 -1
- package/dist/domain/events.js +34 -0
- package/dist/domain/events.js.map +1 -1
- package/dist/engine/index.d.ts +29 -2
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +102 -56
- package/dist/engine/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas/index.d.ts +91 -48
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +13 -0
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/schemas.test.js +1 -0
- package/dist/schemas/schemas.test.js.map +1 -1
- package/dist/store/firebase.d.ts +3 -0
- package/dist/store/firebase.d.ts.map +1 -1
- package/dist/store/firebase.js +21 -0
- package/dist/store/firebase.js.map +1 -1
- package/dist/store/firebase.test.js +17 -0
- package/dist/store/firebase.test.js.map +1 -1
- package/dist/store/index.d.ts +6 -0
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +14 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/sqlite.d.ts +4 -0
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +42 -8
- package/dist/store/sqlite.js.map +1 -1
- package/dist/types/index.d.ts +16 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/webhooks/dispatcher.d.ts +80 -0
- package/dist/webhooks/dispatcher.d.ts.map +1 -0
- package/dist/webhooks/dispatcher.js +133 -0
- package/dist/webhooks/dispatcher.js.map +1 -0
- package/dist/webhooks/index.d.ts +3 -0
- package/dist/webhooks/index.d.ts.map +1 -0
- package/dist/webhooks/index.js +3 -0
- package/dist/webhooks/index.js.map +1 -0
- package/dist/webhooks/signature.d.ts +28 -0
- package/dist/webhooks/signature.d.ts.map +1 -0
- package/dist/webhooks/signature.js +42 -0
- package/dist/webhooks/signature.js.map +1 -0
- package/dist/webhooks/webhooks.test.d.ts +2 -0
- package/dist/webhooks/webhooks.test.d.ts.map +1 -0
- package/dist/webhooks/webhooks.test.js +153 -0
- package/dist/webhooks/webhooks.test.js.map +1 -0
- package/package.json +1 -1
- package/src/domain/events.ts +42 -3
- package/src/engine/index.ts +124 -59
- package/src/index.ts +1 -0
- package/src/schemas/index.ts +17 -0
- package/src/schemas/schemas.test.ts +6 -3
- package/src/store/firebase.test.ts +20 -0
- package/src/store/firebase.ts +22 -0
- package/src/store/index.ts +19 -0
- package/src/store/sqlite.ts +54 -7
- package/src/types/index.ts +15 -28
- package/src/webhooks/dispatcher.ts +190 -0
- package/src/webhooks/index.ts +2 -0
- package/src/webhooks/signature.ts +57 -0
- package/src/webhooks/webhooks.test.ts +200 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/engine/index.ts
CHANGED
|
@@ -17,7 +17,8 @@ import type {
|
|
|
17
17
|
PresignedUrlOptions,
|
|
18
18
|
PresignedUploadResult,
|
|
19
19
|
TimeEntry,
|
|
20
|
-
|
|
20
|
+
Webhook,
|
|
21
|
+
PublicWebhook,
|
|
21
22
|
Workflow,
|
|
22
23
|
CreateTaskInput,
|
|
23
24
|
CreateProjectInput,
|
|
@@ -85,6 +86,9 @@ import { validateAttachmentUrl, AttachmentValidationError } from '../domain/enti
|
|
|
85
86
|
import { validateCustomFieldValues } 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) {
|
|
@@ -319,7 +333,6 @@ export class CriticalPathEngine {
|
|
|
319
333
|
action: 'workflow.created',
|
|
320
334
|
details: { name: created.name }
|
|
321
335
|
});
|
|
322
|
-
this.dispatchWebhook('workflow.created', { workflow: created });
|
|
323
336
|
return created;
|
|
324
337
|
}
|
|
325
338
|
|
|
@@ -347,7 +360,6 @@ export class CriticalPathEngine {
|
|
|
347
360
|
action: 'workflow.updated',
|
|
348
361
|
details: { name: updated.name }
|
|
349
362
|
});
|
|
350
|
-
this.dispatchWebhook('workflow.updated', { workflow: updated, previous: existing });
|
|
351
363
|
}
|
|
352
364
|
return updated;
|
|
353
365
|
}
|
|
@@ -366,7 +378,7 @@ export class CriticalPathEngine {
|
|
|
366
378
|
aggregateId: id,
|
|
367
379
|
aggregateType: 'Workflow',
|
|
368
380
|
occurredAt: now,
|
|
369
|
-
payload: { workflowId: id, name: existing.name }
|
|
381
|
+
payload: { workflowId: id, name: existing.name, tenantId: existing.tenantId }
|
|
370
382
|
};
|
|
371
383
|
await this.events.publish(event);
|
|
372
384
|
|
|
@@ -375,7 +387,6 @@ export class CriticalPathEngine {
|
|
|
375
387
|
action: 'workflow.deleted',
|
|
376
388
|
details: { name: existing.name }
|
|
377
389
|
});
|
|
378
|
-
this.dispatchWebhook('workflow.deleted', { workflowId: id, name: existing.name });
|
|
379
390
|
}
|
|
380
391
|
return deleted;
|
|
381
392
|
}
|
|
@@ -456,7 +467,6 @@ export class CriticalPathEngine {
|
|
|
456
467
|
action: 'project.created',
|
|
457
468
|
details: { name: created.name, key: created.key }
|
|
458
469
|
});
|
|
459
|
-
this.dispatchWebhook('project.created', { project: created });
|
|
460
470
|
return created;
|
|
461
471
|
}
|
|
462
472
|
|
|
@@ -486,7 +496,6 @@ export class CriticalPathEngine {
|
|
|
486
496
|
action: 'project.updated',
|
|
487
497
|
details: { name: updated.name }
|
|
488
498
|
});
|
|
489
|
-
this.dispatchWebhook('project.updated', { project: updated });
|
|
490
499
|
}
|
|
491
500
|
return updated;
|
|
492
501
|
}
|
|
@@ -519,7 +528,7 @@ export class CriticalPathEngine {
|
|
|
519
528
|
aggregateId: id,
|
|
520
529
|
aggregateType: 'Project',
|
|
521
530
|
occurredAt: now,
|
|
522
|
-
payload: { projectId: id, name: existing.name, deletedTaskIds }
|
|
531
|
+
payload: { projectId: id, name: existing.name, deletedTaskIds, tenantId: existing.tenantId }
|
|
523
532
|
};
|
|
524
533
|
await this.events.publish(event);
|
|
525
534
|
|
|
@@ -529,7 +538,6 @@ export class CriticalPathEngine {
|
|
|
529
538
|
action: 'project.deleted',
|
|
530
539
|
details: { name: existing.name, deletedTaskCount: deletedTaskIds.length }
|
|
531
540
|
});
|
|
532
|
-
this.dispatchWebhook('project.deleted', { projectId: id, name: existing.name, deletedTaskIds });
|
|
533
541
|
}
|
|
534
542
|
return deleted;
|
|
535
543
|
}
|
|
@@ -642,7 +650,6 @@ export class CriticalPathEngine {
|
|
|
642
650
|
details: { title: created.title, status: created.status }
|
|
643
651
|
});
|
|
644
652
|
|
|
645
|
-
this.dispatchWebhook('task.created', { task: created });
|
|
646
653
|
return created;
|
|
647
654
|
}
|
|
648
655
|
|
|
@@ -925,10 +932,6 @@ export class CriticalPathEngine {
|
|
|
925
932
|
action: 'task.blocked',
|
|
926
933
|
details: { reason: blockedReason, ...(actorName ? { actorName } : {}) }
|
|
927
934
|
});
|
|
928
|
-
this.dispatchWebhook('task.blocked', {
|
|
929
|
-
task: updated,
|
|
930
|
-
reason: blockedReason
|
|
931
|
-
});
|
|
932
935
|
} else if (wasBlocked && !isNowBlocked) {
|
|
933
936
|
const unblockedEvent: TaskUnblockedEvent = {
|
|
934
937
|
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
@@ -951,9 +954,6 @@ export class CriticalPathEngine {
|
|
|
951
954
|
action: 'task.unblocked',
|
|
952
955
|
details: { ...(actorName ? { actorName } : {}) }
|
|
953
956
|
});
|
|
954
|
-
this.dispatchWebhook('task.unblocked', {
|
|
955
|
-
task: updated
|
|
956
|
-
});
|
|
957
957
|
}
|
|
958
958
|
|
|
959
959
|
await this.store.logActivity({
|
|
@@ -968,10 +968,6 @@ export class CriticalPathEngine {
|
|
|
968
968
|
}
|
|
969
969
|
});
|
|
970
970
|
|
|
971
|
-
this.dispatchWebhook(isStatusChange ? 'task.status_changed' : 'task.updated', {
|
|
972
|
-
task: updated,
|
|
973
|
-
previous: existing
|
|
974
|
-
});
|
|
975
971
|
|
|
976
972
|
return updated;
|
|
977
973
|
}
|
|
@@ -1042,10 +1038,6 @@ export class CriticalPathEngine {
|
|
|
1042
1038
|
details: { unblockedByTaskId: completedTask.id }
|
|
1043
1039
|
});
|
|
1044
1040
|
|
|
1045
|
-
this.dispatchWebhook('task.unblocked', {
|
|
1046
|
-
task: updatedDownstream,
|
|
1047
|
-
upstreamTaskId: completedTask.id
|
|
1048
|
-
});
|
|
1049
1041
|
}
|
|
1050
1042
|
}
|
|
1051
1043
|
} catch (err) {
|
|
@@ -1086,7 +1078,6 @@ export class CriticalPathEngine {
|
|
|
1086
1078
|
action: 'task.deleted',
|
|
1087
1079
|
details: { title: existing.title }
|
|
1088
1080
|
});
|
|
1089
|
-
this.dispatchWebhook('task.deleted', { taskId: id, title: existing.title });
|
|
1090
1081
|
}
|
|
1091
1082
|
return deleted;
|
|
1092
1083
|
}
|
|
@@ -1297,7 +1288,6 @@ export class CriticalPathEngine {
|
|
|
1297
1288
|
};
|
|
1298
1289
|
await this.events.publish(event);
|
|
1299
1290
|
|
|
1300
|
-
this.dispatchWebhook('comment.created', { comment: created });
|
|
1301
1291
|
return created;
|
|
1302
1292
|
}
|
|
1303
1293
|
|
|
@@ -1326,7 +1316,6 @@ export class CriticalPathEngine {
|
|
|
1326
1316
|
};
|
|
1327
1317
|
await this.events.publish(event);
|
|
1328
1318
|
|
|
1329
|
-
this.dispatchWebhook('comment.updated', { comment: updated });
|
|
1330
1319
|
return updated;
|
|
1331
1320
|
}
|
|
1332
1321
|
|
|
@@ -1351,7 +1340,6 @@ export class CriticalPathEngine {
|
|
|
1351
1340
|
};
|
|
1352
1341
|
await this.events.publish(event);
|
|
1353
1342
|
|
|
1354
|
-
this.dispatchWebhook('comment.deleted', { commentId: id, taskId: existing.taskId });
|
|
1355
1343
|
}
|
|
1356
1344
|
return deleted;
|
|
1357
1345
|
}
|
|
@@ -1399,7 +1387,6 @@ export class CriticalPathEngine {
|
|
|
1399
1387
|
};
|
|
1400
1388
|
await this.events.publish(event);
|
|
1401
1389
|
|
|
1402
|
-
this.dispatchWebhook('comment.reaction.added', { comment: updated, reaction });
|
|
1403
1390
|
return updated;
|
|
1404
1391
|
}
|
|
1405
1392
|
|
|
@@ -1432,7 +1419,6 @@ export class CriticalPathEngine {
|
|
|
1432
1419
|
};
|
|
1433
1420
|
await this.events.publish(event);
|
|
1434
1421
|
|
|
1435
|
-
this.dispatchWebhook('comment.reaction.removed', { comment: updated, reaction });
|
|
1436
1422
|
return updated;
|
|
1437
1423
|
}
|
|
1438
1424
|
|
|
@@ -1481,7 +1467,6 @@ export class CriticalPathEngine {
|
|
|
1481
1467
|
};
|
|
1482
1468
|
await this.events.publish(event);
|
|
1483
1469
|
|
|
1484
|
-
this.dispatchWebhook('attachment.created', { attachment: created });
|
|
1485
1470
|
return created;
|
|
1486
1471
|
}
|
|
1487
1472
|
|
|
@@ -1510,11 +1495,15 @@ export class CriticalPathEngine {
|
|
|
1510
1495
|
aggregateId: id,
|
|
1511
1496
|
aggregateType: 'Attachment',
|
|
1512
1497
|
occurredAt: now,
|
|
1513
|
-
payload: {
|
|
1498
|
+
payload: {
|
|
1499
|
+
attachmentId: id,
|
|
1500
|
+
storageKey: existing.storageKey,
|
|
1501
|
+
url: existing.url,
|
|
1502
|
+
projectId: await this.projectIdOfAttachment(existing)
|
|
1503
|
+
}
|
|
1514
1504
|
};
|
|
1515
1505
|
await this.events.publish(event);
|
|
1516
1506
|
|
|
1517
|
-
this.dispatchWebhook('attachment.deleted', { attachmentId: id, taskId: existing.taskId, projectId: existing.projectId });
|
|
1518
1507
|
}
|
|
1519
1508
|
return deleted;
|
|
1520
1509
|
}
|
|
@@ -1639,7 +1628,6 @@ export class CriticalPathEngine {
|
|
|
1639
1628
|
};
|
|
1640
1629
|
await this.events.publish(event);
|
|
1641
1630
|
|
|
1642
|
-
this.dispatchWebhook('team.created', { team: created });
|
|
1643
1631
|
return created;
|
|
1644
1632
|
}
|
|
1645
1633
|
|
|
@@ -1682,7 +1670,6 @@ export class CriticalPathEngine {
|
|
|
1682
1670
|
};
|
|
1683
1671
|
await this.events.publish(event);
|
|
1684
1672
|
|
|
1685
|
-
this.dispatchWebhook('container.created', { container: created });
|
|
1686
1673
|
return created;
|
|
1687
1674
|
}
|
|
1688
1675
|
|
|
@@ -1743,7 +1730,6 @@ export class CriticalPathEngine {
|
|
|
1743
1730
|
action: 'deliverable.created',
|
|
1744
1731
|
details: { title: created.title, format: created.format }
|
|
1745
1732
|
});
|
|
1746
|
-
this.dispatchWebhook('deliverable.created', { deliverable: created });
|
|
1747
1733
|
return created;
|
|
1748
1734
|
}
|
|
1749
1735
|
|
|
@@ -1774,11 +1760,6 @@ export class CriticalPathEngine {
|
|
|
1774
1760
|
}
|
|
1775
1761
|
};
|
|
1776
1762
|
await this.events.publish(statusEvent);
|
|
1777
|
-
this.dispatchWebhook('deliverable.status_changed', {
|
|
1778
|
-
deliverable: updated,
|
|
1779
|
-
previousStatus: existing.status,
|
|
1780
|
-
newStatus: updated.status
|
|
1781
|
-
});
|
|
1782
1763
|
}
|
|
1783
1764
|
|
|
1784
1765
|
const updateEvent: DeliverableUpdatedEvent = {
|
|
@@ -1797,7 +1778,6 @@ export class CriticalPathEngine {
|
|
|
1797
1778
|
action: 'deliverable.updated',
|
|
1798
1779
|
details: { title: updated.title, status: updated.status }
|
|
1799
1780
|
});
|
|
1800
|
-
this.dispatchWebhook('deliverable.updated', { deliverable: updated });
|
|
1801
1781
|
}
|
|
1802
1782
|
return updated;
|
|
1803
1783
|
}
|
|
@@ -1826,7 +1806,6 @@ export class CriticalPathEngine {
|
|
|
1826
1806
|
action: 'deliverable.deleted',
|
|
1827
1807
|
details: { title: existing.title }
|
|
1828
1808
|
});
|
|
1829
|
-
this.dispatchWebhook('deliverable.deleted', { deliverableId: id, projectId: existing.projectId });
|
|
1830
1809
|
}
|
|
1831
1810
|
return deleted;
|
|
1832
1811
|
}
|
|
@@ -1904,7 +1883,6 @@ export class CriticalPathEngine {
|
|
|
1904
1883
|
payload: { iteration: created }
|
|
1905
1884
|
};
|
|
1906
1885
|
await this.events.publish(event);
|
|
1907
|
-
this.dispatchWebhook('iteration.started', { iteration: created });
|
|
1908
1886
|
}
|
|
1909
1887
|
return created;
|
|
1910
1888
|
}
|
|
@@ -1927,7 +1905,6 @@ export class CriticalPathEngine {
|
|
|
1927
1905
|
payload: { iteration: updated }
|
|
1928
1906
|
};
|
|
1929
1907
|
await this.events.publish(event);
|
|
1930
|
-
this.dispatchWebhook('iteration.started', { iteration: updated });
|
|
1931
1908
|
} else if (existing.status !== 'completed' && updated.status === 'completed') {
|
|
1932
1909
|
const event: IterationCompletedEvent = {
|
|
1933
1910
|
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
@@ -1938,7 +1915,6 @@ export class CriticalPathEngine {
|
|
|
1938
1915
|
payload: { iteration: updated }
|
|
1939
1916
|
};
|
|
1940
1917
|
await this.events.publish(event);
|
|
1941
|
-
this.dispatchWebhook('iteration.completed', { iteration: updated });
|
|
1942
1918
|
}
|
|
1943
1919
|
}
|
|
1944
1920
|
return updated;
|
|
@@ -2123,19 +2099,108 @@ export class CriticalPathEngine {
|
|
|
2123
2099
|
);
|
|
2124
2100
|
}
|
|
2125
2101
|
|
|
2126
|
-
|
|
2102
|
+
// --- Webhooks ---
|
|
2103
|
+
|
|
2104
|
+
/** Webhooks from `config.webhooks` (static, not editable) plus those in the store. */
|
|
2105
|
+
private async listAllWebhooks(): Promise<Webhook[]> {
|
|
2106
|
+
const fromConfig: Webhook[] = (this.config.webhooks ?? []).map((w, i) => ({
|
|
2107
|
+
...w,
|
|
2108
|
+
id: `config_${i}`,
|
|
2109
|
+
createdAt: new Date(0).toISOString()
|
|
2110
|
+
}));
|
|
2111
|
+
return [...fromConfig, ...(await this.store.getWebhooks())];
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
private toPublicWebhook({ secret, ...webhook }: Webhook): PublicWebhook {
|
|
2115
|
+
return { ...webhook, hasSecret: !!secret };
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
/** Webhooks in the actor's tenant, with secrets redacted. Requires `workspace.manage` on views. */
|
|
2119
|
+
async getWebhooks(): Promise<PublicWebhook[]> {
|
|
2120
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
2127
2121
|
const webhooks = await this.store.getWebhooks();
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2122
|
+
return webhooks.filter((w) => this.inActorTenant(w)).map((w) => this.toPublicWebhook(w));
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
async getWebhook(id: string): Promise<PublicWebhook | null> {
|
|
2126
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
2127
|
+
const webhook = await this.store.getWebhook(id);
|
|
2128
|
+
return webhook && this.inActorTenant(webhook) ? this.toPublicWebhook(webhook) : null;
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
/**
|
|
2132
|
+
* Registers a webhook. The signing secret is returned only here; generate one by omitting
|
|
2133
|
+
* `secret`. Deliveries carry `X-CriticalPath-Signature` (see `verifyWebhookSignature`).
|
|
2134
|
+
*/
|
|
2135
|
+
async createWebhook(input: {
|
|
2136
|
+
name: string;
|
|
2137
|
+
url: string;
|
|
2138
|
+
events: Webhook['events'];
|
|
2139
|
+
secret?: string;
|
|
2140
|
+
active?: boolean;
|
|
2141
|
+
}): Promise<{ webhook: PublicWebhook; secret: string }> {
|
|
2142
|
+
await this.requireWorkspaceAccess('workspace.manage');
|
|
2143
|
+
this.validateWebhookUrl(input.url);
|
|
2144
|
+
const secret = input.secret ?? generateWebhookSecret();
|
|
2145
|
+
const created = await this.store.addWebhook({
|
|
2146
|
+
name: input.name,
|
|
2147
|
+
url: input.url,
|
|
2148
|
+
events: input.events,
|
|
2149
|
+
secret,
|
|
2150
|
+
active: input.active ?? true,
|
|
2151
|
+
tenantId: this.actor?.tenantId
|
|
2152
|
+
});
|
|
2153
|
+
this.webhooks.invalidate();
|
|
2154
|
+
return { webhook: this.toPublicWebhook(created), secret };
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
async updateWebhook(
|
|
2158
|
+
id: string,
|
|
2159
|
+
updates: Partial<Pick<Webhook, 'name' | 'url' | 'events' | 'active' | 'secret'>>
|
|
2160
|
+
): Promise<PublicWebhook | null> {
|
|
2161
|
+
if (!(await this.getWebhook(id))) return null;
|
|
2162
|
+
if (updates.url) this.validateWebhookUrl(updates.url);
|
|
2163
|
+
const { name, url, events, active, secret } = updates;
|
|
2164
|
+
const updated = await this.store.updateWebhook(
|
|
2165
|
+
id,
|
|
2166
|
+
Object.fromEntries(Object.entries({ name, url, events, active, secret }).filter(([, v]) => v !== undefined))
|
|
2167
|
+
);
|
|
2168
|
+
this.webhooks.invalidate();
|
|
2169
|
+
return updated ? this.toPublicWebhook(updated) : null;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
async deleteWebhook(id: string): Promise<boolean> {
|
|
2173
|
+
if (!(await this.getWebhook(id))) return false;
|
|
2174
|
+
const deleted = await this.store.deleteWebhook(id);
|
|
2175
|
+
this.webhooks.invalidate();
|
|
2176
|
+
return deleted;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
private validateWebhookUrl(url: string): void {
|
|
2180
|
+
try {
|
|
2181
|
+
assertWebhookUrl(url, this.config.webhookDelivery?.allowPrivateUrls);
|
|
2182
|
+
} catch (err) {
|
|
2183
|
+
throw new ValidationError((err as Error).message);
|
|
2138
2184
|
}
|
|
2139
2185
|
}
|
|
2140
|
-
}
|
|
2141
2186
|
|
|
2187
|
+
/** Finds the tenant an event belongs to, from the entity in its payload or its project. */
|
|
2188
|
+
private async resolveEventTenant(event: DomainEvent): Promise<string | undefined> {
|
|
2189
|
+
const p = event.payload as Record<string, any>;
|
|
2190
|
+
const owner = p.project ?? p.workflow ?? p.team;
|
|
2191
|
+
if (owner) return owner.tenantId ?? undefined;
|
|
2192
|
+
// Deleted projects and workflows carry their tenant in the payload.
|
|
2193
|
+
if ('tenantId' in p) return p.tenantId ?? undefined;
|
|
2194
|
+
|
|
2195
|
+
const projectId: string | undefined =
|
|
2196
|
+
p.projectId ??
|
|
2197
|
+
p.task?.projectId ??
|
|
2198
|
+
p.deliverable?.projectId ??
|
|
2199
|
+
p.iteration?.projectId ??
|
|
2200
|
+
p.container?.projectId ??
|
|
2201
|
+
p.attachment?.projectId ??
|
|
2202
|
+
(await this.projectIdOfTask(p.taskId ?? p.comment?.taskId ?? p.attachment?.taskId ?? p.dependency?.taskId ?? p.timeEntry?.taskId));
|
|
2203
|
+
if (!projectId) return undefined;
|
|
2204
|
+
return (await this.store.getProject(projectId))?.tenantId ?? undefined;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
package/src/index.ts
CHANGED
package/src/schemas/index.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { z } from 'zod';
|
|
12
12
|
import { ValidationError, type ValidationIssue } from '../domain/errors.js';
|
|
13
|
+
import { DOMAIN_EVENT_NAMES } from '../domain/events.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Parses `data` with `schema`, returning the cleaned value (unknown keys stripped, defaults
|
|
@@ -322,6 +323,20 @@ export const LogTimeSchema = strictObject({
|
|
|
322
323
|
loggedAt: isoString.optional()
|
|
323
324
|
});
|
|
324
325
|
|
|
326
|
+
// --- Webhooks ---
|
|
327
|
+
|
|
328
|
+
export const CreateWebhookSchema = strictObject({
|
|
329
|
+
name: nonEmpty,
|
|
330
|
+
/** http(s) URL; private and local addresses are rejected unless the engine allows them. */
|
|
331
|
+
url: nonEmpty,
|
|
332
|
+
/** Domain event names (e.g. `task.created`) or `'*'` for all events. */
|
|
333
|
+
events: z.array(z.enum(['*', ...DOMAIN_EVENT_NAMES])).min(1),
|
|
334
|
+
/** Signing secret (at least 16 characters). Generated when omitted and returned once. */
|
|
335
|
+
secret: z.string().min(16).optional(),
|
|
336
|
+
active: z.boolean().optional()
|
|
337
|
+
});
|
|
338
|
+
export const UpdateWebhookSchema = CreateWebhookSchema.partial();
|
|
339
|
+
|
|
325
340
|
export type CreateTaskPayload = z.infer<typeof CreateTaskSchema>;
|
|
326
341
|
export type UpdateTaskPayload = z.infer<typeof UpdateTaskSchema>;
|
|
327
342
|
export type CreateProjectPayload = z.infer<typeof CreateProjectSchema>;
|
|
@@ -350,4 +365,6 @@ export type CreateAttachmentBody = z.input<typeof CreateAttachmentSchema>;
|
|
|
350
365
|
export type UploadAttachmentBody = z.input<typeof UploadAttachmentSchema>;
|
|
351
366
|
export type PresignAttachmentBody = z.input<typeof PresignAttachmentSchema>;
|
|
352
367
|
export type LogTimeBody = z.input<typeof LogTimeSchema>;
|
|
368
|
+
export type CreateWebhookBody = z.input<typeof CreateWebhookSchema>;
|
|
369
|
+
export type UpdateWebhookBody = z.input<typeof UpdateWebhookSchema>;
|
|
353
370
|
|
|
@@ -13,7 +13,8 @@ import type {
|
|
|
13
13
|
Iteration,
|
|
14
14
|
Comment,
|
|
15
15
|
CreateAttachmentInput,
|
|
16
|
-
TimeEntry
|
|
16
|
+
TimeEntry,
|
|
17
|
+
Webhook
|
|
17
18
|
} from '../types/index.js';
|
|
18
19
|
import {
|
|
19
20
|
CreateTaskSchema,
|
|
@@ -30,7 +31,8 @@ import {
|
|
|
30
31
|
CreateCommentSchema,
|
|
31
32
|
UpdateCommentSchema,
|
|
32
33
|
CreateAttachmentSchema,
|
|
33
|
-
LogTimeSchema
|
|
34
|
+
LogTimeSchema,
|
|
35
|
+
CreateWebhookSchema
|
|
34
36
|
} from './index.js';
|
|
35
37
|
|
|
36
38
|
/**
|
|
@@ -69,7 +71,8 @@ const keyChecks: true[] = [
|
|
|
69
71
|
true as SameKeys<Infer<typeof CreateCommentSchema>, Omit<Comment, ServerAssigned | 'reactions' | CommentIdentity>>,
|
|
70
72
|
true as SameKeys<Infer<typeof UpdateCommentSchema>, Pick<Comment, 'content' | 'mentions' | 'metadata'>>,
|
|
71
73
|
true as SameKeys<Infer<typeof CreateAttachmentSchema>, Omit<CreateAttachmentInput, UploaderIdentity>>,
|
|
72
|
-
true as SameKeys<Infer<typeof LogTimeSchema>, Omit<TimeEntry, 'id' | 'userId'
|
|
74
|
+
true as SameKeys<Infer<typeof LogTimeSchema>, Omit<TimeEntry, 'id' | 'userId'>>,
|
|
75
|
+
true as SameKeys<Infer<typeof CreateWebhookSchema>, Pick<Webhook, 'name' | 'url' | 'events' | 'secret' | 'active'>>
|
|
73
76
|
];
|
|
74
77
|
|
|
75
78
|
describe('request schemas', () => {
|
|
@@ -107,5 +107,25 @@ describe('FirebaseStore', () => {
|
|
|
107
107
|
expect(byUpstream).toHaveLength(1);
|
|
108
108
|
expect(byUpstream[0].id).toBe(dep.id);
|
|
109
109
|
});
|
|
110
|
+
|
|
111
|
+
it('should create, update, clear fields on, and delete webhooks', async () => {
|
|
112
|
+
const created = await store.addWebhook({
|
|
113
|
+
name: 'Hook',
|
|
114
|
+
url: 'https://hooks.example.com/x',
|
|
115
|
+
events: ['task.created'],
|
|
116
|
+
secret: 'whsec_test',
|
|
117
|
+
active: true,
|
|
118
|
+
tenantId: 'acme'
|
|
119
|
+
});
|
|
120
|
+
expect(await store.getWebhook(created.id)).toMatchObject({ name: 'Hook', tenantId: 'acme' });
|
|
121
|
+
|
|
122
|
+
const updated = await store.updateWebhook(created.id, { events: ['*'], secret: undefined });
|
|
123
|
+
expect(updated?.events).toEqual(['*']);
|
|
124
|
+
expect((await store.getWebhook(created.id))?.secret).toBeUndefined();
|
|
125
|
+
|
|
126
|
+
expect(await store.deleteWebhook(created.id)).toBe(true);
|
|
127
|
+
expect(await store.getWebhook(created.id)).toBeNull();
|
|
128
|
+
expect(await store.deleteWebhook(created.id)).toBe(false);
|
|
129
|
+
});
|
|
110
130
|
});
|
|
111
131
|
|
package/src/store/firebase.ts
CHANGED
|
@@ -573,6 +573,11 @@ export class FirebaseStore implements StorageAdapter {
|
|
|
573
573
|
return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
|
|
574
574
|
}
|
|
575
575
|
|
|
576
|
+
async getWebhook(id: string): Promise<Webhook | null> {
|
|
577
|
+
const doc = await this.db.collection('webhooks').doc(id).get();
|
|
578
|
+
return doc.exists ? ({ ...doc.data(), id: doc.id } as Webhook) : null;
|
|
579
|
+
}
|
|
580
|
+
|
|
576
581
|
async addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook> {
|
|
577
582
|
const docRef = this.db.collection('webhooks').doc();
|
|
578
583
|
const now = new Date().toISOString();
|
|
@@ -580,4 +585,21 @@ export class FirebaseStore implements StorageAdapter {
|
|
|
580
585
|
await docRef.set(sanitizeFirestoreData(newWh));
|
|
581
586
|
return newWh;
|
|
582
587
|
}
|
|
588
|
+
|
|
589
|
+
async updateWebhook(id: string, updates: Partial<Omit<Webhook, 'id' | 'createdAt'>>): Promise<Webhook | null> {
|
|
590
|
+
const existing = await this.getWebhook(id);
|
|
591
|
+
if (!existing) return null;
|
|
592
|
+
const updated: Webhook = { ...existing, ...updates, id, createdAt: existing.createdAt };
|
|
593
|
+
// Write the whole document (no merge) so fields cleared with `undefined` are removed.
|
|
594
|
+
await this.db.collection('webhooks').doc(id).set(sanitizeFirestoreData(updated));
|
|
595
|
+
return updated;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async deleteWebhook(id: string): Promise<boolean> {
|
|
599
|
+
const ref = this.db.collection('webhooks').doc(id);
|
|
600
|
+
const doc = await ref.get();
|
|
601
|
+
if (!doc.exists) return false;
|
|
602
|
+
await ref.delete();
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
583
605
|
}
|
package/src/store/index.ts
CHANGED
|
@@ -101,7 +101,10 @@ export interface DependencyRepository {
|
|
|
101
101
|
|
|
102
102
|
export interface WebhookRepository {
|
|
103
103
|
getWebhooks(): Promise<Webhook[]>;
|
|
104
|
+
getWebhook(id: string): Promise<Webhook | null>;
|
|
104
105
|
addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook>;
|
|
106
|
+
updateWebhook(id: string, updates: Partial<Omit<Webhook, 'id' | 'createdAt'>>): Promise<Webhook | null>;
|
|
107
|
+
deleteWebhook(id: string): Promise<boolean>;
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
export interface DeliverableRepository {
|
|
@@ -538,10 +541,26 @@ export class InMemoryStore implements StorageAdapter {
|
|
|
538
541
|
return Array.from(this.webhooks.values());
|
|
539
542
|
}
|
|
540
543
|
|
|
544
|
+
async getWebhook(id: string): Promise<Webhook | null> {
|
|
545
|
+
return this.webhooks.get(id) ?? null;
|
|
546
|
+
}
|
|
547
|
+
|
|
541
548
|
async addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook> {
|
|
542
549
|
const id = `wh_${Math.random().toString(36).substring(2, 9)}`;
|
|
543
550
|
const newWh: Webhook = { ...webhook, id, createdAt: new Date().toISOString() };
|
|
544
551
|
this.webhooks.set(id, newWh);
|
|
545
552
|
return newWh;
|
|
546
553
|
}
|
|
554
|
+
|
|
555
|
+
async updateWebhook(id: string, updates: Partial<Omit<Webhook, 'id' | 'createdAt'>>): Promise<Webhook | null> {
|
|
556
|
+
const existing = this.webhooks.get(id);
|
|
557
|
+
if (!existing) return null;
|
|
558
|
+
const updated: Webhook = { ...existing, ...updates, id, createdAt: existing.createdAt };
|
|
559
|
+
this.webhooks.set(id, updated);
|
|
560
|
+
return updated;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async deleteWebhook(id: string): Promise<boolean> {
|
|
564
|
+
return this.webhooks.delete(id);
|
|
565
|
+
}
|
|
547
566
|
}
|
package/src/store/sqlite.ts
CHANGED
|
@@ -280,6 +280,14 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
280
280
|
// Column may already exist
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
for (const column of ['name TEXT', 'tenantId TEXT']) {
|
|
284
|
+
try {
|
|
285
|
+
this.db.exec(`ALTER TABLE webhooks ADD COLUMN ${column}`);
|
|
286
|
+
} catch {
|
|
287
|
+
// Column may already exist
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
283
291
|
for (const table of ['projects', 'workflows', 'teams']) {
|
|
284
292
|
try {
|
|
285
293
|
this.db.exec(`ALTER TABLE ${table} ADD COLUMN tenantId TEXT`);
|
|
@@ -1175,11 +1183,48 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
1175
1183
|
async getWebhooks(): Promise<Webhook[]> {
|
|
1176
1184
|
const stmt = this.db.prepare('SELECT * FROM webhooks');
|
|
1177
1185
|
const rows = stmt.all() as any[];
|
|
1178
|
-
return rows.map((r) => (
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1186
|
+
return rows.map((r) => this.mapWebhook(r));
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
async getWebhook(id: string): Promise<Webhook | null> {
|
|
1190
|
+
const row = this.db.prepare('SELECT * FROM webhooks WHERE id = ?').get(id) as any;
|
|
1191
|
+
return row ? this.mapWebhook(row) : null;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
async updateWebhook(id: string, updates: Partial<Omit<Webhook, 'id' | 'createdAt'>>): Promise<Webhook | null> {
|
|
1195
|
+
const existing = await this.getWebhook(id);
|
|
1196
|
+
if (!existing) return null;
|
|
1197
|
+
const updated: Webhook = { ...existing, ...updates, id, createdAt: existing.createdAt };
|
|
1198
|
+
this.db
|
|
1199
|
+
.prepare('UPDATE webhooks SET name = ?, url = ?, events = ?, secret = ?, active = ?, tenantId = ? WHERE id = ?')
|
|
1200
|
+
.run(
|
|
1201
|
+
updated.name ?? null,
|
|
1202
|
+
updated.url,
|
|
1203
|
+
JSON.stringify(updated.events),
|
|
1204
|
+
updated.secret ?? null,
|
|
1205
|
+
updated.active ? 1 : 0,
|
|
1206
|
+
updated.tenantId ?? null,
|
|
1207
|
+
id
|
|
1208
|
+
);
|
|
1209
|
+
return updated;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
async deleteWebhook(id: string): Promise<boolean> {
|
|
1213
|
+
const result = this.db.prepare('DELETE FROM webhooks WHERE id = ?').run(id);
|
|
1214
|
+
return Number(result.changes) > 0;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
private mapWebhook(row: any): Webhook {
|
|
1218
|
+
return {
|
|
1219
|
+
id: row.id,
|
|
1220
|
+
name: row.name ?? '',
|
|
1221
|
+
url: row.url,
|
|
1222
|
+
events: JSON.parse(row.events),
|
|
1223
|
+
secret: row.secret ?? undefined,
|
|
1224
|
+
active: Boolean(row.active),
|
|
1225
|
+
tenantId: row.tenantId ?? undefined,
|
|
1226
|
+
createdAt: row.createdAt
|
|
1227
|
+
};
|
|
1183
1228
|
}
|
|
1184
1229
|
|
|
1185
1230
|
async addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook> {
|
|
@@ -1188,15 +1233,17 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
1188
1233
|
const newWh: Webhook = { ...webhook, id, createdAt: now };
|
|
1189
1234
|
|
|
1190
1235
|
const stmt = this.db.prepare(`
|
|
1191
|
-
INSERT INTO webhooks (id, url, events, secret, active, createdAt)
|
|
1192
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
1236
|
+
INSERT INTO webhooks (id, name, url, events, secret, active, tenantId, createdAt)
|
|
1237
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1193
1238
|
`);
|
|
1194
1239
|
stmt.run(
|
|
1195
1240
|
newWh.id,
|
|
1241
|
+
newWh.name ?? null,
|
|
1196
1242
|
newWh.url,
|
|
1197
1243
|
JSON.stringify(newWh.events),
|
|
1198
1244
|
newWh.secret || null,
|
|
1199
1245
|
newWh.active ? 1 : 0,
|
|
1246
|
+
newWh.tenantId ?? null,
|
|
1200
1247
|
newWh.createdAt
|
|
1201
1248
|
);
|
|
1202
1249
|
return newWh;
|