@critical-path/core 0.25.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +25 -0
  3. package/dist/domain/authorization.d.ts +23 -0
  4. package/dist/domain/authorization.d.ts.map +1 -0
  5. package/dist/domain/authorization.js +62 -0
  6. package/dist/domain/authorization.js.map +1 -0
  7. package/dist/domain/entities.d.ts +2 -2
  8. package/dist/domain/entities.d.ts.map +1 -1
  9. package/dist/domain/entities.js.map +1 -1
  10. package/dist/domain/errors.d.ts +6 -0
  11. package/dist/domain/errors.d.ts.map +1 -1
  12. package/dist/domain/errors.js +9 -0
  13. package/dist/domain/errors.js.map +1 -1
  14. package/dist/domain/events.d.ts +5 -0
  15. package/dist/domain/events.d.ts.map +1 -1
  16. package/dist/domain/events.js +34 -0
  17. package/dist/domain/events.js.map +1 -1
  18. package/dist/domain/index.d.ts +1 -0
  19. package/dist/domain/index.d.ts.map +1 -1
  20. package/dist/domain/index.js +1 -0
  21. package/dist/domain/index.js.map +1 -1
  22. package/dist/engine/authorization.test.d.ts +2 -0
  23. package/dist/engine/authorization.test.d.ts.map +1 -0
  24. package/dist/engine/authorization.test.js +150 -0
  25. package/dist/engine/authorization.test.js.map +1 -0
  26. package/dist/engine/index.d.ts +65 -3
  27. package/dist/engine/index.d.ts.map +1 -1
  28. package/dist/engine/index.js +404 -104
  29. package/dist/engine/index.js.map +1 -1
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/schemas/index.d.ts +131 -54
  35. package/dist/schemas/index.d.ts.map +1 -1
  36. package/dist/schemas/index.js +18 -1
  37. package/dist/schemas/index.js.map +1 -1
  38. package/dist/schemas/schemas.test.js +1 -0
  39. package/dist/schemas/schemas.test.js.map +1 -1
  40. package/dist/store/firebase.d.ts +3 -0
  41. package/dist/store/firebase.d.ts.map +1 -1
  42. package/dist/store/firebase.js +21 -0
  43. package/dist/store/firebase.js.map +1 -1
  44. package/dist/store/firebase.test.js +17 -0
  45. package/dist/store/firebase.test.js.map +1 -1
  46. package/dist/store/index.d.ts +6 -0
  47. package/dist/store/index.d.ts.map +1 -1
  48. package/dist/store/index.js +14 -0
  49. package/dist/store/index.js.map +1 -1
  50. package/dist/store/sqlite.d.ts +4 -0
  51. package/dist/store/sqlite.d.ts.map +1 -1
  52. package/dist/store/sqlite.js +62 -17
  53. package/dist/store/sqlite.js.map +1 -1
  54. package/dist/types/index.d.ts +61 -2
  55. package/dist/types/index.d.ts.map +1 -1
  56. package/dist/webhooks/dispatcher.d.ts +80 -0
  57. package/dist/webhooks/dispatcher.d.ts.map +1 -0
  58. package/dist/webhooks/dispatcher.js +133 -0
  59. package/dist/webhooks/dispatcher.js.map +1 -0
  60. package/dist/webhooks/index.d.ts +3 -0
  61. package/dist/webhooks/index.d.ts.map +1 -0
  62. package/dist/webhooks/index.js +3 -0
  63. package/dist/webhooks/index.js.map +1 -0
  64. package/dist/webhooks/signature.d.ts +28 -0
  65. package/dist/webhooks/signature.d.ts.map +1 -0
  66. package/dist/webhooks/signature.js +42 -0
  67. package/dist/webhooks/signature.js.map +1 -0
  68. package/dist/webhooks/webhooks.test.d.ts +2 -0
  69. package/dist/webhooks/webhooks.test.d.ts.map +1 -0
  70. package/dist/webhooks/webhooks.test.js +153 -0
  71. package/dist/webhooks/webhooks.test.js.map +1 -0
  72. package/package.json +1 -1
  73. package/src/domain/authorization.ts +82 -0
  74. package/src/domain/entities.ts +2 -1
  75. package/src/domain/errors.ts +10 -0
  76. package/src/domain/events.ts +42 -3
  77. package/src/domain/index.ts +1 -0
  78. package/src/engine/authorization.test.ts +191 -0
  79. package/src/engine/index.ts +419 -108
  80. package/src/index.ts +1 -0
  81. package/src/schemas/index.ts +22 -1
  82. package/src/schemas/schemas.test.ts +9 -5
  83. package/src/store/firebase.test.ts +20 -0
  84. package/src/store/firebase.ts +22 -0
  85. package/src/store/index.ts +19 -0
  86. package/src/store/sqlite.ts +74 -13
  87. package/src/types/index.ts +81 -29
  88. package/src/webhooks/dispatcher.ts +190 -0
  89. package/src/webhooks/index.ts +2 -0
  90. package/src/webhooks/signature.ts +57 -0
  91. package/src/webhooks/webhooks.test.ts +200 -0
  92. package/tsconfig.tsbuildinfo +1 -1
@@ -7,7 +7,9 @@ import { DomainEventBus } from '../domain/events.js';
7
7
  import { validateAttachmentUrl } from '../domain/entities.js';
8
8
  import { validateCustomFieldValues } from '../domain/custom-fields.js';
9
9
  import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
10
- import { ValidationError, NotFoundError } from '../domain/errors.js';
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,9 +20,13 @@ 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;
28
+ /** Set on internal views that keep the actor for attribution but skip authorization. */
29
+ authorizationBypassed;
24
30
  constructor(config = {}) {
25
31
  this.config = config;
26
32
  this.store = typeof config.store === 'object' && config.store !== null
@@ -29,6 +35,11 @@ export class CriticalPathEngine {
29
35
  this.fileStorage = config.fileStorage;
30
36
  this.plugins = new PluginRegistry();
31
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));
32
43
  if (config.plugins) {
33
44
  for (const plugin of config.plugins) {
34
45
  this.plugins.register(plugin);
@@ -53,6 +64,102 @@ export class CriticalPathEngine {
53
64
  actorIdOr(fallback) {
54
65
  return this.actor?.userId ?? fallback;
55
66
  }
67
+ // --- Authorization (applies only to withActor views; the base engine is trusted) ---
68
+ /**
69
+ * True when calls must be checked: on a withActor view (not an internal elevated view) with a
70
+ * policy or a tenant to enforce. Otherwise there is nothing to check, so lookups are skipped.
71
+ */
72
+ get enforcing() {
73
+ return !!this.actor && !this.authorizationBypassed && (!!this.config.authorize || !!this.actor.tenantId);
74
+ }
75
+ /** A view that keeps the actor for attribution but skips checks, for cascades already authorized. */
76
+ elevated() {
77
+ if (!this.enforcing)
78
+ return this;
79
+ const view = Object.create(this);
80
+ Object.defineProperty(view, 'authorizationBypassed', { value: true });
81
+ return view;
82
+ }
83
+ inActorTenant(entity) {
84
+ const tenant = this.actor?.tenantId;
85
+ return !tenant || entity?.tenantId === tenant;
86
+ }
87
+ async isAllowed(action, project, resource) {
88
+ if (!this.enforcing)
89
+ return true;
90
+ if (project && !this.inActorTenant(project))
91
+ return false;
92
+ const policy = this.config.authorize;
93
+ return policy ? await policy({ actor: this.actor, action, project, resource }) : true;
94
+ }
95
+ /** The project if the actor may read it, otherwise null (callers report "not found"). */
96
+ async readableProject(projectId) {
97
+ if (!projectId)
98
+ return null;
99
+ const project = await this.store.getProject(projectId);
100
+ if (!project)
101
+ return null;
102
+ return (await this.isAllowed('project.read', project)) ? project : null;
103
+ }
104
+ async canReadProject(projectId) {
105
+ if (!this.enforcing)
106
+ return true;
107
+ return (await this.readableProject(projectId)) !== null;
108
+ }
109
+ /**
110
+ * Ensures the actor may perform `action` in `projectId`. Projects the actor cannot read (or
111
+ * that belong to another tenant) are reported as not found so their existence is not revealed.
112
+ */
113
+ async requireProjectAccess(action, projectId, resource) {
114
+ if (!this.enforcing)
115
+ return;
116
+ const project = await this.readableProject(projectId);
117
+ if (!project)
118
+ throw new NotFoundError(`Project "${projectId}" not found.`);
119
+ if (action !== 'project.read' && !(await this.isAllowed(action, project, resource))) {
120
+ throw new ForbiddenError(`Not allowed to ${action} in project "${projectId}".`);
121
+ }
122
+ }
123
+ async requireWorkspaceAccess(action) {
124
+ if (!(await this.isAllowed(action)))
125
+ throw new ForbiddenError(`Not allowed to ${action}.`);
126
+ }
127
+ /** Keeps only items in projects the actor may read. Items with no project are hidden on views. */
128
+ async filterReadable(items, projectIdOf) {
129
+ if (!this.enforcing)
130
+ return items;
131
+ const readable = new Map();
132
+ const result = [];
133
+ for (const item of items) {
134
+ const projectId = await projectIdOf(item);
135
+ if (!projectId)
136
+ continue;
137
+ if (!readable.has(projectId))
138
+ readable.set(projectId, await this.canReadProject(projectId));
139
+ if (readable.get(projectId))
140
+ result.push(item);
141
+ }
142
+ return result;
143
+ }
144
+ async projectIdOfTask(taskId) {
145
+ return taskId ? (await this.store.getTask(taskId))?.projectId : undefined;
146
+ }
147
+ async projectIdOfComment(commentId) {
148
+ if (!commentId)
149
+ return undefined;
150
+ const comment = await this.store.getComment(commentId);
151
+ return this.projectIdOfTask(comment?.taskId);
152
+ }
153
+ async projectIdOfAttachment(a) {
154
+ return a.projectId ?? (await this.projectIdOfTask(a.taskId)) ?? (await this.projectIdOfComment(a.commentId));
155
+ }
156
+ /** Strips fields only the engine may set (tenancy) from updates made through a view. */
157
+ withoutTenant(updates) {
158
+ if (!this.actor || !('tenantId' in updates))
159
+ return updates;
160
+ const { tenantId: _ignored, ...rest } = updates;
161
+ return rest;
162
+ }
56
163
  async seedInitialData(data) {
57
164
  if (data.workflows) {
58
165
  for (const wf of data.workflows) {
@@ -92,13 +199,16 @@ export class CriticalPathEngine {
92
199
  }
93
200
  // --- Workflows ---
94
201
  async getWorkflows() {
95
- return this.store.getWorkflows();
202
+ const workflows = await this.store.getWorkflows();
203
+ return workflows.filter((w) => this.inActorTenant(w));
96
204
  }
97
205
  async getWorkflow(id) {
98
- return this.store.getWorkflow(id);
206
+ const workflow = await this.store.getWorkflow(id);
207
+ return workflow && this.inActorTenant(workflow) ? workflow : null;
99
208
  }
100
209
  async createWorkflow(workflow) {
101
- const created = await this.store.createWorkflow(workflow);
210
+ await this.requireWorkspaceAccess('workspace.manage');
211
+ const created = await this.store.createWorkflow({ ...workflow, tenantId: this.actor ? this.actor.tenantId : workflow.tenantId });
102
212
  const now = new Date().toISOString();
103
213
  const event = {
104
214
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -114,13 +224,14 @@ export class CriticalPathEngine {
114
224
  action: 'workflow.created',
115
225
  details: { name: created.name }
116
226
  });
117
- this.dispatchWebhook('workflow.created', { workflow: created });
118
227
  return created;
119
228
  }
120
229
  async updateWorkflow(id, updates) {
121
- const existing = await this.store.getWorkflow(id);
230
+ const existing = await this.getWorkflow(id);
122
231
  if (!existing)
123
232
  return null;
233
+ await this.requireWorkspaceAccess('workspace.manage');
234
+ updates = this.withoutTenant(updates);
124
235
  const updated = await this.store.updateWorkflow(id, updates);
125
236
  if (updated) {
126
237
  const now = new Date().toISOString();
@@ -138,14 +249,14 @@ export class CriticalPathEngine {
138
249
  action: 'workflow.updated',
139
250
  details: { name: updated.name }
140
251
  });
141
- this.dispatchWebhook('workflow.updated', { workflow: updated, previous: existing });
142
252
  }
143
253
  return updated;
144
254
  }
145
255
  async deleteWorkflow(id) {
146
- const existing = await this.store.getWorkflow(id);
256
+ const existing = await this.getWorkflow(id);
147
257
  if (!existing)
148
258
  return false;
259
+ await this.requireWorkspaceAccess('workspace.manage');
149
260
  const deleted = await this.store.deleteWorkflow(id);
150
261
  if (deleted) {
151
262
  const now = new Date().toISOString();
@@ -155,7 +266,7 @@ export class CriticalPathEngine {
155
266
  aggregateId: id,
156
267
  aggregateType: 'Workflow',
157
268
  occurredAt: now,
158
- payload: { workflowId: id, name: existing.name }
269
+ payload: { workflowId: id, name: existing.name, tenantId: existing.tenantId }
159
270
  };
160
271
  await this.events.publish(event);
161
272
  await this.store.logActivity({
@@ -163,7 +274,6 @@ export class CriticalPathEngine {
163
274
  action: 'workflow.deleted',
164
275
  details: { name: existing.name }
165
276
  });
166
- this.dispatchWebhook('workflow.deleted', { workflowId: id, name: existing.name });
167
277
  }
168
278
  return deleted;
169
279
  }
@@ -176,7 +286,8 @@ export class CriticalPathEngine {
176
286
  if (wf)
177
287
  return wf;
178
288
  }
179
- const workflows = await this.store.getWorkflows();
289
+ // Only fall back to workflows from the project's own tenant.
290
+ const workflows = (await this.store.getWorkflows()).filter((w) => w.tenantId === project?.tenantId);
180
291
  const defaultWf = workflows.find((w) => w.isDefault);
181
292
  if (defaultWf)
182
293
  return defaultWf;
@@ -185,21 +296,21 @@ export class CriticalPathEngine {
185
296
  return DEFAULT_SOFTWARE_WORKFLOW;
186
297
  }
187
298
  async getAllowedTaskTransitions(taskId) {
188
- const task = await this.store.getTask(taskId);
299
+ const task = await this.getTask(taskId);
189
300
  if (!task)
190
301
  return [];
191
302
  const workflow = await this.resolveProjectWorkflow(task.projectId);
192
303
  return getAllowedTransitions(workflow || undefined, task.status);
193
304
  }
194
305
  async getAllowedNextTaskTransitions(taskId) {
195
- const task = await this.store.getTask(taskId);
306
+ const task = await this.getTask(taskId);
196
307
  if (!task)
197
308
  return [];
198
309
  const workflow = await this.resolveProjectWorkflow(task.projectId);
199
310
  return getAllowedNextStatuses(workflow || undefined, task.status);
200
311
  }
201
312
  async getAllowedPreviousTaskTransitions(taskId) {
202
- const task = await this.store.getTask(taskId);
313
+ const task = await this.getTask(taskId);
203
314
  if (!task)
204
315
  return [];
205
316
  const workflow = await this.resolveProjectWorkflow(task.projectId);
@@ -207,13 +318,23 @@ export class CriticalPathEngine {
207
318
  }
208
319
  // --- Projects ---
209
320
  async getProjects() {
210
- return this.store.getProjects();
321
+ const projects = await this.store.getProjects();
322
+ return this.filterReadable(projects, (p) => p.id);
211
323
  }
212
324
  async getProject(id) {
213
- return this.store.getProject(id);
325
+ if (!this.enforcing)
326
+ return this.store.getProject(id);
327
+ return this.readableProject(id);
214
328
  }
215
329
  async createProject(project) {
216
- const created = await this.store.createProject(project);
330
+ await this.requireWorkspaceAccess('project.create');
331
+ // Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
332
+ const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
333
+ // The creator administers the project they create.
334
+ if (this.actor && this.actor.userId !== 'anonymous' && !input.members?.some((m) => m.userId === this.actor.userId)) {
335
+ input.members = [...(input.members ?? []), { userId: this.actor.userId, role: 'admin' }];
336
+ }
337
+ const created = await this.store.createProject(input);
217
338
  const now = new Date().toISOString();
218
339
  const event = {
219
340
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -230,13 +351,16 @@ export class CriticalPathEngine {
230
351
  action: 'project.created',
231
352
  details: { name: created.name, key: created.key }
232
353
  });
233
- this.dispatchWebhook('project.created', { project: created });
234
354
  return created;
235
355
  }
236
356
  async updateProject(id, updates) {
237
- const existing = await this.store.getProject(id);
357
+ const existing = await this.getProject(id);
238
358
  if (!existing)
239
359
  return null;
360
+ await this.requireProjectAccess('project.update', id);
361
+ if ('members' in updates)
362
+ await this.requireProjectAccess('project.manage_members', id);
363
+ updates = this.withoutTenant(updates);
240
364
  const updated = await this.store.updateProject(id, updates);
241
365
  if (updated) {
242
366
  const now = new Date().toISOString();
@@ -255,7 +379,6 @@ export class CriticalPathEngine {
255
379
  action: 'project.updated',
256
380
  details: { name: updated.name }
257
381
  });
258
- this.dispatchWebhook('project.updated', { project: updated });
259
382
  }
260
383
  return updated;
261
384
  }
@@ -264,13 +387,16 @@ export class CriticalPathEngine {
264
387
  * hooks run and `task.deleted` events fire before `project.deleted` is published.
265
388
  */
266
389
  async deleteProject(id) {
267
- const existing = await this.store.getProject(id);
390
+ const existing = await this.getProject(id);
268
391
  if (!existing)
269
392
  return false;
393
+ await this.requireProjectAccess('project.delete', id);
394
+ // Deleting the project implies deleting its tasks; skip per-task checks for the cascade.
395
+ const cascade = this.elevated();
270
396
  const tasks = await this.store.getTasks(id);
271
397
  const deletedTaskIds = [];
272
398
  for (const task of tasks) {
273
- if (await this.deleteTask(task.id)) {
399
+ if (await cascade.deleteTask(task.id)) {
274
400
  deletedTaskIds.push(task.id);
275
401
  }
276
402
  }
@@ -283,7 +409,7 @@ export class CriticalPathEngine {
283
409
  aggregateId: id,
284
410
  aggregateType: 'Project',
285
411
  occurredAt: now,
286
- payload: { projectId: id, name: existing.name, deletedTaskIds }
412
+ payload: { projectId: id, name: existing.name, deletedTaskIds, tenantId: existing.tenantId }
287
413
  };
288
414
  await this.events.publish(event);
289
415
  await this.store.logActivity({
@@ -292,18 +418,24 @@ export class CriticalPathEngine {
292
418
  action: 'project.deleted',
293
419
  details: { name: existing.name, deletedTaskCount: deletedTaskIds.length }
294
420
  });
295
- this.dispatchWebhook('project.deleted', { projectId: id, name: existing.name, deletedTaskIds });
296
421
  }
297
422
  return deleted;
298
423
  }
299
424
  // --- Tasks ---
300
425
  async getTasks(projectId) {
301
- return this.store.getTasks(projectId);
426
+ if (projectId && !(await this.canReadProject(projectId)))
427
+ return [];
428
+ const tasks = await this.store.getTasks(projectId);
429
+ return projectId ? tasks : this.filterReadable(tasks, (t) => t.projectId);
302
430
  }
303
431
  async getTask(id) {
304
- return this.store.getTask(id);
432
+ const task = await this.store.getTask(id);
433
+ if (!task)
434
+ return null;
435
+ return (await this.canReadProject(task.projectId)) ? task : null;
305
436
  }
306
437
  async createTask(taskInput) {
438
+ await this.requireProjectAccess('task.create', taskInput.projectId);
307
439
  if (this.actor && !taskInput.reporterId) {
308
440
  taskInput = { ...taskInput, reporterId: this.actor.userId };
309
441
  }
@@ -385,13 +517,13 @@ export class CriticalPathEngine {
385
517
  action: 'task.created',
386
518
  details: { title: created.title, status: created.status }
387
519
  });
388
- this.dispatchWebhook('task.created', { task: created });
389
520
  return created;
390
521
  }
391
522
  async updateTask(id, updates, options) {
392
- const existing = await this.store.getTask(id);
523
+ const existing = await this.getTask(id);
393
524
  if (!existing)
394
525
  return null;
526
+ await this.requireProjectAccess('task.update', existing.projectId);
395
527
  // Identity comes from trusted options or the withActor view, never from the update payload.
396
528
  options = options ?? (this.actor ? { actor: this.actor } : undefined);
397
529
  const actorId = options?.actorId || options?.actor?.userId || 'system';
@@ -629,10 +761,6 @@ export class CriticalPathEngine {
629
761
  action: 'task.blocked',
630
762
  details: { reason: blockedReason, ...(actorName ? { actorName } : {}) }
631
763
  });
632
- this.dispatchWebhook('task.blocked', {
633
- task: updated,
634
- reason: blockedReason
635
- });
636
764
  }
637
765
  else if (wasBlocked && !isNowBlocked) {
638
766
  const unblockedEvent = {
@@ -656,9 +784,6 @@ export class CriticalPathEngine {
656
784
  action: 'task.unblocked',
657
785
  details: { ...(actorName ? { actorName } : {}) }
658
786
  });
659
- this.dispatchWebhook('task.unblocked', {
660
- task: updated
661
- });
662
787
  }
663
788
  await this.store.logActivity({
664
789
  projectId: updated.projectId,
@@ -671,10 +796,6 @@ export class CriticalPathEngine {
671
796
  ...(actorName ? { actorName } : {})
672
797
  }
673
798
  });
674
- this.dispatchWebhook(isStatusChange ? 'task.status_changed' : 'task.updated', {
675
- task: updated,
676
- previous: existing
677
- });
678
799
  return updated;
679
800
  }
680
801
  async checkAndUnblockDownstreamTasks(completedTask) {
@@ -736,10 +857,6 @@ export class CriticalPathEngine {
736
857
  action: 'task.unblocked',
737
858
  details: { unblockedByTaskId: completedTask.id }
738
859
  });
739
- this.dispatchWebhook('task.unblocked', {
740
- task: updatedDownstream,
741
- upstreamTaskId: completedTask.id
742
- });
743
860
  }
744
861
  }
745
862
  }
@@ -748,9 +865,10 @@ export class CriticalPathEngine {
748
865
  }
749
866
  }
750
867
  async deleteTask(id) {
751
- const existing = await this.store.getTask(id);
868
+ const existing = await this.getTask(id);
752
869
  if (!existing)
753
870
  return false;
871
+ await this.requireProjectAccess('task.delete', existing.projectId);
754
872
  await this.plugins.runBeforeTaskDelete(id);
755
873
  const deleted = await this.store.deleteTask(id);
756
874
  if (deleted) {
@@ -776,12 +894,19 @@ export class CriticalPathEngine {
776
894
  action: 'task.deleted',
777
895
  details: { title: existing.title }
778
896
  });
779
- this.dispatchWebhook('task.deleted', { taskId: id, title: existing.title });
780
897
  }
781
898
  return deleted;
782
899
  }
783
900
  // --- Task Dependencies & Graph ---
784
901
  async addDependency(dep) {
902
+ if (this.enforcing) {
903
+ const [task, upstream] = await Promise.all([this.getTask(dep.taskId), this.getTask(dep.dependsOnTaskId)]);
904
+ if (!task)
905
+ throw new NotFoundError(`Task "${dep.taskId}" not found.`);
906
+ if (!upstream)
907
+ throw new NotFoundError(`Task "${dep.dependsOnTaskId}" not found.`);
908
+ await this.requireProjectAccess('task.update', task.projectId);
909
+ }
785
910
  // Enforce Directed Acyclic Graph (DAG) Invariant
786
911
  const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
787
912
  const cycleCheck = detectDependencyCycle(reachableDeps, dep);
@@ -830,6 +955,9 @@ export class CriticalPathEngine {
830
955
  return Array.from(edges.values());
831
956
  }
832
957
  async getTaskDependencyGraph(taskId) {
958
+ if (this.enforcing && !(await this.getTask(taskId))) {
959
+ return { taskId, upstreamTasks: [], downstreamTasks: [], dependencies: [] };
960
+ }
833
961
  const dependencies = await this.store.getDependencies(taskId);
834
962
  const upstreamTaskIds = new Set();
835
963
  const downstreamTaskIds = new Set();
@@ -841,8 +969,8 @@ export class CriticalPathEngine {
841
969
  downstreamTaskIds.add(dep.taskId);
842
970
  }
843
971
  }
844
- const upstreamTasks = (await Promise.all(Array.from(upstreamTaskIds).map((id) => this.store.getTask(id)))).filter((t) => t !== null);
845
- const downstreamTasks = (await Promise.all(Array.from(downstreamTaskIds).map((id) => this.store.getTask(id)))).filter((t) => t !== null);
972
+ const upstreamTasks = (await Promise.all(Array.from(upstreamTaskIds).map((id) => this.getTask(id)))).filter((t) => t !== null);
973
+ const downstreamTasks = (await Promise.all(Array.from(downstreamTaskIds).map((id) => this.getTask(id)))).filter((t) => t !== null);
846
974
  return {
847
975
  taskId,
848
976
  upstreamTasks,
@@ -871,7 +999,12 @@ export class CriticalPathEngine {
871
999
  if (!Number.isFinite(entry.hours) || entry.hours <= 0) {
872
1000
  throw new ValidationError('Logged hours must be a positive number.');
873
1001
  }
874
- const task = await this.store.getTask(entry.taskId);
1002
+ const task = await this.getTask(entry.taskId);
1003
+ if (this.enforcing) {
1004
+ if (!task)
1005
+ throw new NotFoundError(`Task "${entry.taskId}" not found.`);
1006
+ await this.requireProjectAccess('time.log', task.projectId);
1007
+ }
875
1008
  if (task) {
876
1009
  const newLoggedHours = (task.loggedHours || 0) + entry.hours;
877
1010
  const newActualHours = (task.actualHours || 0) + entry.hours;
@@ -902,12 +1035,32 @@ export class CriticalPathEngine {
902
1035
  }
903
1036
  // --- Comments ---
904
1037
  async getComments(taskId) {
1038
+ if (this.enforcing && !(await this.getTask(taskId)))
1039
+ return [];
905
1040
  return this.store.getComments(taskId);
906
1041
  }
907
1042
  async getComment(id) {
908
- return this.store.getComment(id);
1043
+ const comment = await this.store.getComment(id);
1044
+ if (!comment)
1045
+ return null;
1046
+ return (await this.canReadProject(await this.projectIdOfTask(comment.taskId))) ? comment : null;
1047
+ }
1048
+ async getActivities(filter) {
1049
+ const activities = await this.store.getActivities(filter);
1050
+ return this.filterReadable(activities, (a) => a.projectId ?? this.projectIdOfTask(a.taskId));
1051
+ }
1052
+ async getTimeEntries(taskId) {
1053
+ if (this.enforcing && !(await this.getTask(taskId)))
1054
+ return [];
1055
+ return this.store.getTimeEntries(taskId);
909
1056
  }
910
1057
  async addComment(input) {
1058
+ if (this.enforcing) {
1059
+ const task = await this.getTask(input.taskId);
1060
+ if (!task)
1061
+ throw new NotFoundError(`Task "${input.taskId}" not found.`);
1062
+ await this.requireProjectAccess('comment.create', task.projectId);
1063
+ }
911
1064
  const comment = this.actor
912
1065
  ? { ...input, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' }
913
1066
  : { ...input, authorId: input.authorId ?? 'system' };
@@ -923,13 +1076,16 @@ export class CriticalPathEngine {
923
1076
  payload: { taskId: comment.taskId, comment: created }
924
1077
  };
925
1078
  await this.events.publish(event);
926
- this.dispatchWebhook('comment.created', { comment: created });
927
1079
  return created;
928
1080
  }
929
1081
  async updateComment(id, updates) {
930
- const existing = await this.store.getComment(id);
1082
+ const existing = await this.getComment(id);
931
1083
  if (!existing)
932
1084
  return null;
1085
+ await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
1086
+ type: 'comment',
1087
+ ownerId: existing.authorId
1088
+ });
933
1089
  const mentions = updates.mentions ?? (updates.content ? extractMentions(updates.content) : undefined);
934
1090
  const toUpdate = mentions !== undefined ? { ...updates, mentions } : updates;
935
1091
  const updated = await this.store.updateComment(id, toUpdate);
@@ -945,13 +1101,16 @@ export class CriticalPathEngine {
945
1101
  payload: { comment: updated, previous: existing }
946
1102
  };
947
1103
  await this.events.publish(event);
948
- this.dispatchWebhook('comment.updated', { comment: updated });
949
1104
  return updated;
950
1105
  }
951
1106
  async deleteComment(id) {
952
- const existing = await this.store.getComment(id);
1107
+ const existing = await this.getComment(id);
953
1108
  if (!existing)
954
1109
  return false;
1110
+ await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
1111
+ type: 'comment',
1112
+ ownerId: existing.authorId
1113
+ });
955
1114
  const deleted = await this.store.deleteComment(id);
956
1115
  if (deleted) {
957
1116
  const now = new Date().toISOString();
@@ -964,15 +1123,15 @@ export class CriticalPathEngine {
964
1123
  payload: { taskId: existing.taskId, commentId: id }
965
1124
  };
966
1125
  await this.events.publish(event);
967
- this.dispatchWebhook('comment.deleted', { commentId: id, taskId: existing.taskId });
968
1126
  }
969
1127
  return deleted;
970
1128
  }
971
1129
  async addCommentReaction(commentId, input) {
972
1130
  const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
973
- const existing = await this.store.getComment(commentId);
1131
+ const existing = await this.getComment(commentId);
974
1132
  if (!existing)
975
1133
  return null;
1134
+ await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
976
1135
  let updated;
977
1136
  if (this.store.addReaction) {
978
1137
  updated = await this.store.addReaction(commentId, reaction);
@@ -1009,14 +1168,14 @@ export class CriticalPathEngine {
1009
1168
  }
1010
1169
  };
1011
1170
  await this.events.publish(event);
1012
- this.dispatchWebhook('comment.reaction.added', { comment: updated, reaction });
1013
1171
  return updated;
1014
1172
  }
1015
1173
  async removeCommentReaction(commentId, input) {
1016
1174
  const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
1017
- const existing = await this.store.getComment(commentId);
1175
+ const existing = await this.getComment(commentId);
1018
1176
  if (!existing)
1019
1177
  return null;
1178
+ await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
1020
1179
  let updated;
1021
1180
  if (this.store.removeReaction) {
1022
1181
  updated = await this.store.removeReaction(commentId, reaction);
@@ -1037,21 +1196,32 @@ export class CriticalPathEngine {
1037
1196
  payload: { commentId, reaction, comment: updated }
1038
1197
  };
1039
1198
  await this.events.publish(event);
1040
- this.dispatchWebhook('comment.reaction.removed', { comment: updated, reaction });
1041
1199
  return updated;
1042
1200
  }
1043
1201
  // --- Attachments ---
1044
1202
  async getAttachments(filter) {
1045
- const attachments = await this.store.getAttachments(filter);
1203
+ const attachments = await this.filterReadable(await this.store.getAttachments(filter), (a) => this.projectIdOfAttachment(a));
1046
1204
  if (filter?.artifactType) {
1047
1205
  return attachments.filter(a => a.artifactType === filter.artifactType);
1048
1206
  }
1049
1207
  return attachments;
1050
1208
  }
1051
1209
  async getAttachment(id) {
1052
- return this.store.getAttachment(id);
1210
+ const attachment = await this.store.getAttachment(id);
1211
+ if (!attachment)
1212
+ return null;
1213
+ return (await this.canReadProject(await this.projectIdOfAttachment(attachment))) ? attachment : null;
1214
+ }
1215
+ async requireAttachmentCreate(link) {
1216
+ if (!this.enforcing)
1217
+ return;
1218
+ const projectId = await this.projectIdOfAttachment(link);
1219
+ if (!projectId)
1220
+ throw new ValidationError('Attachments must reference a project, task or comment.');
1221
+ await this.requireProjectAccess('attachment.create', projectId);
1053
1222
  }
1054
1223
  async createAttachment(input) {
1224
+ await this.requireAttachmentCreate(input);
1055
1225
  const attachment = this.actor
1056
1226
  ? { ...input, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' }
1057
1227
  : { ...input, uploaderId: input.uploaderId ?? 'system' };
@@ -1067,13 +1237,16 @@ export class CriticalPathEngine {
1067
1237
  payload: { attachment: created }
1068
1238
  };
1069
1239
  await this.events.publish(event);
1070
- this.dispatchWebhook('attachment.created', { attachment: created });
1071
1240
  return created;
1072
1241
  }
1073
1242
  async deleteAttachment(id) {
1074
- const existing = await this.store.getAttachment(id);
1243
+ const existing = await this.getAttachment(id);
1075
1244
  if (!existing)
1076
1245
  return false;
1246
+ await this.requireProjectAccess('attachment.delete', await this.projectIdOfAttachment(existing), {
1247
+ type: 'attachment',
1248
+ ownerId: existing.uploaderId
1249
+ });
1077
1250
  if (existing.storageKey && this.fileStorage) {
1078
1251
  try {
1079
1252
  await this.fileStorage.delete(existing.storageKey);
@@ -1091,10 +1264,14 @@ export class CriticalPathEngine {
1091
1264
  aggregateId: id,
1092
1265
  aggregateType: 'Attachment',
1093
1266
  occurredAt: now,
1094
- payload: { attachmentId: id, storageKey: existing.storageKey, url: existing.url }
1267
+ payload: {
1268
+ attachmentId: id,
1269
+ storageKey: existing.storageKey,
1270
+ url: existing.url,
1271
+ projectId: await this.projectIdOfAttachment(existing)
1272
+ }
1095
1273
  };
1096
1274
  await this.events.publish(event);
1097
- this.dispatchWebhook('attachment.deleted', { attachmentId: id, taskId: existing.taskId, projectId: existing.projectId });
1098
1275
  }
1099
1276
  return deleted;
1100
1277
  }
@@ -1102,6 +1279,8 @@ export class CriticalPathEngine {
1102
1279
  if (!this.fileStorage) {
1103
1280
  throw new Error('No FileStorageAdapter configured in CriticalPathEngine. Pass "fileStorage" in config to enable direct file uploads.');
1104
1281
  }
1282
+ // Check before uploading so denied requests do not leave orphaned files behind.
1283
+ await this.requireAttachmentCreate(input);
1105
1284
  const uploadResult = await this.fileStorage.upload({
1106
1285
  filename: input.filename,
1107
1286
  data: input.data,
@@ -1155,17 +1334,33 @@ export class CriticalPathEngine {
1155
1334
  if (!this.fileStorage || !this.fileStorage.getPresignedUploadUrl) {
1156
1335
  throw new Error('Presigned uploads are not supported by the configured FileStorageAdapter.');
1157
1336
  }
1158
- return this.fileStorage.getPresignedUploadUrl(options);
1337
+ const { projectId, ...presign } = options;
1338
+ if (this.enforcing) {
1339
+ // Scoped callers may only write under their project's prefix.
1340
+ if (!projectId)
1341
+ throw new ValidationError('projectId is required for presigned uploads.');
1342
+ await this.requireProjectAccess('attachment.create', projectId);
1343
+ const prefix = `projects/${projectId}/`;
1344
+ const key = presign.storageKey.replace(/^\/+/, '');
1345
+ if (key.split('/').some((segment) => segment === '..' || segment === '.')) {
1346
+ throw new ValidationError('storageKey must not contain "." or ".." segments.');
1347
+ }
1348
+ presign.storageKey = key.startsWith(prefix) ? key : prefix + key;
1349
+ }
1350
+ return this.fileStorage.getPresignedUploadUrl(presign);
1159
1351
  }
1160
1352
  // --- Teams ---
1161
1353
  async getTeams() {
1162
- return this.store.getTeams();
1354
+ const teams = await this.store.getTeams();
1355
+ return teams.filter((t) => this.inActorTenant(t));
1163
1356
  }
1164
1357
  async getTeam(id) {
1165
- return this.store.getTeam(id);
1358
+ const team = await this.store.getTeam(id);
1359
+ return team && this.inActorTenant(team) ? team : null;
1166
1360
  }
1167
1361
  async createTeam(team) {
1168
- const created = await this.store.createTeam(team);
1362
+ await this.requireWorkspaceAccess('workspace.manage');
1363
+ const created = await this.store.createTeam({ ...team, tenantId: this.actor ? this.actor.tenantId : team.tenantId });
1169
1364
  const now = new Date().toISOString();
1170
1365
  const event = {
1171
1366
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -1176,23 +1371,34 @@ export class CriticalPathEngine {
1176
1371
  payload: { team: created }
1177
1372
  };
1178
1373
  await this.events.publish(event);
1179
- this.dispatchWebhook('team.created', { team: created });
1180
1374
  return created;
1181
1375
  }
1182
1376
  async updateTeam(id, updates) {
1183
- return this.store.updateTeam(id, updates);
1377
+ if (!(await this.getTeam(id)))
1378
+ return null;
1379
+ await this.requireWorkspaceAccess('workspace.manage');
1380
+ return this.store.updateTeam(id, this.withoutTenant(updates));
1184
1381
  }
1185
1382
  async deleteTeam(id) {
1383
+ if (!(await this.getTeam(id)))
1384
+ return false;
1385
+ await this.requireWorkspaceAccess('workspace.manage');
1186
1386
  return this.store.deleteTeam(id);
1187
1387
  }
1188
1388
  // --- Containers ---
1189
1389
  async getContainers(projectId) {
1390
+ if (!(await this.canReadProject(projectId)))
1391
+ return [];
1190
1392
  return this.store.getContainers(projectId);
1191
1393
  }
1192
1394
  async getContainer(id) {
1193
- return this.store.getContainer(id);
1395
+ const container = await this.store.getContainer(id);
1396
+ if (!container)
1397
+ return null;
1398
+ return (await this.canReadProject(container.projectId)) ? container : null;
1194
1399
  }
1195
1400
  async createContainer(container) {
1401
+ await this.requireProjectAccess('plan.manage', container.projectId);
1196
1402
  const created = await this.store.createContainer(container);
1197
1403
  const now = new Date().toISOString();
1198
1404
  const event = {
@@ -1204,23 +1410,36 @@ export class CriticalPathEngine {
1204
1410
  payload: { container: created }
1205
1411
  };
1206
1412
  await this.events.publish(event);
1207
- this.dispatchWebhook('container.created', { container: created });
1208
1413
  return created;
1209
1414
  }
1210
1415
  async updateContainer(id, updates) {
1416
+ const existing = await this.getContainer(id);
1417
+ if (!existing)
1418
+ return null;
1419
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1211
1420
  return this.store.updateContainer(id, updates);
1212
1421
  }
1213
1422
  async deleteContainer(id) {
1423
+ const existing = await this.getContainer(id);
1424
+ if (!existing)
1425
+ return false;
1426
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1214
1427
  return this.store.deleteContainer(id);
1215
1428
  }
1216
1429
  // --- Deliverables ---
1217
1430
  async getDeliverables(projectId) {
1431
+ if (!(await this.canReadProject(projectId)))
1432
+ return [];
1218
1433
  return this.store.getDeliverables(projectId);
1219
1434
  }
1220
1435
  async getDeliverable(id) {
1221
- return this.store.getDeliverable(id);
1436
+ const deliverable = await this.store.getDeliverable(id);
1437
+ if (!deliverable)
1438
+ return null;
1439
+ return (await this.canReadProject(deliverable.projectId)) ? deliverable : null;
1222
1440
  }
1223
1441
  async createDeliverable(input) {
1442
+ await this.requireProjectAccess('plan.manage', input.projectId);
1224
1443
  const project = await this.store.getProject(input.projectId);
1225
1444
  if (project?.customFieldDefinitions && input.customFields) {
1226
1445
  validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
@@ -1247,13 +1466,13 @@ export class CriticalPathEngine {
1247
1466
  action: 'deliverable.created',
1248
1467
  details: { title: created.title, format: created.format }
1249
1468
  });
1250
- this.dispatchWebhook('deliverable.created', { deliverable: created });
1251
1469
  return created;
1252
1470
  }
1253
1471
  async updateDeliverable(id, updates) {
1254
- const existing = await this.store.getDeliverable(id);
1472
+ const existing = await this.getDeliverable(id);
1255
1473
  if (!existing)
1256
1474
  return null;
1475
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1257
1476
  if (updates.status && updates.status === 'delivered' && !existing.deliveredAt && !updates.deliveredAt) {
1258
1477
  updates.deliveredAt = new Date().toISOString();
1259
1478
  }
@@ -1274,11 +1493,6 @@ export class CriticalPathEngine {
1274
1493
  }
1275
1494
  };
1276
1495
  await this.events.publish(statusEvent);
1277
- this.dispatchWebhook('deliverable.status_changed', {
1278
- deliverable: updated,
1279
- previousStatus: existing.status,
1280
- newStatus: updated.status
1281
- });
1282
1496
  }
1283
1497
  const updateEvent = {
1284
1498
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -1295,14 +1509,14 @@ export class CriticalPathEngine {
1295
1509
  action: 'deliverable.updated',
1296
1510
  details: { title: updated.title, status: updated.status }
1297
1511
  });
1298
- this.dispatchWebhook('deliverable.updated', { deliverable: updated });
1299
1512
  }
1300
1513
  return updated;
1301
1514
  }
1302
1515
  async deleteDeliverable(id) {
1303
- const existing = await this.store.getDeliverable(id);
1516
+ const existing = await this.getDeliverable(id);
1304
1517
  if (!existing)
1305
1518
  return false;
1519
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1306
1520
  const deleted = await this.store.deleteDeliverable(id);
1307
1521
  if (deleted) {
1308
1522
  const now = new Date().toISOString();
@@ -1321,12 +1535,11 @@ export class CriticalPathEngine {
1321
1535
  action: 'deliverable.deleted',
1322
1536
  details: { title: existing.title }
1323
1537
  });
1324
- this.dispatchWebhook('deliverable.deleted', { deliverableId: id, projectId: existing.projectId });
1325
1538
  }
1326
1539
  return deleted;
1327
1540
  }
1328
1541
  async getDeliverableSummary(deliverableId) {
1329
- const deliverable = await this.store.getDeliverable(deliverableId);
1542
+ const deliverable = await this.getDeliverable(deliverableId);
1330
1543
  if (!deliverable)
1331
1544
  return null;
1332
1545
  const project = await this.store.getProject(deliverable.projectId);
@@ -1371,12 +1584,18 @@ export class CriticalPathEngine {
1371
1584
  }
1372
1585
  // --- Iterations ---
1373
1586
  async getIterations(projectId) {
1587
+ if (!(await this.canReadProject(projectId)))
1588
+ return [];
1374
1589
  return this.store.getIterations(projectId);
1375
1590
  }
1376
1591
  async getIteration(id) {
1377
- return this.store.getIteration(id);
1592
+ const iteration = await this.store.getIteration(id);
1593
+ if (!iteration)
1594
+ return null;
1595
+ return (await this.canReadProject(iteration.projectId)) ? iteration : null;
1378
1596
  }
1379
1597
  async createIteration(iteration) {
1598
+ await this.requireProjectAccess('plan.manage', iteration.projectId);
1380
1599
  const created = await this.store.createIteration(iteration);
1381
1600
  if (created.status === 'active') {
1382
1601
  const now = new Date().toISOString();
@@ -1389,14 +1608,14 @@ export class CriticalPathEngine {
1389
1608
  payload: { iteration: created }
1390
1609
  };
1391
1610
  await this.events.publish(event);
1392
- this.dispatchWebhook('iteration.started', { iteration: created });
1393
1611
  }
1394
1612
  return created;
1395
1613
  }
1396
1614
  async updateIteration(id, updates) {
1397
- const existing = await this.store.getIteration(id);
1615
+ const existing = await this.getIteration(id);
1398
1616
  if (!existing)
1399
1617
  return null;
1618
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1400
1619
  const updated = await this.store.updateIteration(id, updates);
1401
1620
  if (updated) {
1402
1621
  const now = new Date().toISOString();
@@ -1410,7 +1629,6 @@ export class CriticalPathEngine {
1410
1629
  payload: { iteration: updated }
1411
1630
  };
1412
1631
  await this.events.publish(event);
1413
- this.dispatchWebhook('iteration.started', { iteration: updated });
1414
1632
  }
1415
1633
  else if (existing.status !== 'completed' && updated.status === 'completed') {
1416
1634
  const event = {
@@ -1422,16 +1640,20 @@ export class CriticalPathEngine {
1422
1640
  payload: { iteration: updated }
1423
1641
  };
1424
1642
  await this.events.publish(event);
1425
- this.dispatchWebhook('iteration.completed', { iteration: updated });
1426
1643
  }
1427
1644
  }
1428
1645
  return updated;
1429
1646
  }
1430
1647
  async deleteIteration(id) {
1648
+ const existing = await this.getIteration(id);
1649
+ if (!existing)
1650
+ return false;
1651
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1431
1652
  return this.store.deleteIteration(id);
1432
1653
  }
1433
1654
  // --- Ladder of Abstraction & Critical Path Method ---
1434
1655
  async calculateCriticalPath(projectId, options = {}) {
1656
+ await this.requireProjectAccess('project.read', projectId);
1435
1657
  const project = await this.store.getProject(projectId);
1436
1658
  const tasks = await this.store.getTasks(projectId);
1437
1659
  const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
@@ -1450,6 +1672,7 @@ export class CriticalPathEngine {
1450
1672
  return calculateCPM(projectId, tasks, dependencies, { schedule, projectStartDate });
1451
1673
  }
1452
1674
  async getTimelineLadder(projectId, options = {}) {
1675
+ await this.requireProjectAccess('project.read', projectId);
1453
1676
  const project = await this.store.getProject(projectId);
1454
1677
  if (!project) {
1455
1678
  throw new NotFoundError(`Project with ID "${projectId}" not found.`);
@@ -1496,7 +1719,7 @@ export class CriticalPathEngine {
1496
1719
  }, options);
1497
1720
  }
1498
1721
  async getTaskLadder(taskId) {
1499
- const task = await this.store.getTask(taskId);
1722
+ const task = await this.getTask(taskId);
1500
1723
  if (!task)
1501
1724
  return null;
1502
1725
  const project = await this.store.getProject(task.projectId);
@@ -1518,7 +1741,7 @@ export class CriticalPathEngine {
1518
1741
  };
1519
1742
  }
1520
1743
  async getTaskMetrics(taskId, options) {
1521
- const task = await this.store.getTask(taskId);
1744
+ const task = await this.getTask(taskId);
1522
1745
  if (!task)
1523
1746
  return null;
1524
1747
  const [activities, timeEntries, project, workflow] = await Promise.all([
@@ -1534,7 +1757,7 @@ export class CriticalPathEngine {
1534
1757
  });
1535
1758
  }
1536
1759
  async getTaskProgressHistory(taskId, options) {
1537
- const task = await this.store.getTask(taskId);
1760
+ const task = await this.getTask(taskId);
1538
1761
  if (!task)
1539
1762
  return null;
1540
1763
  const [activities, project, workflow] = await Promise.all([
@@ -1549,8 +1772,10 @@ export class CriticalPathEngine {
1549
1772
  });
1550
1773
  }
1551
1774
  async getWorkloadDistribution(projectId, options = {}) {
1552
- const tasks = await this.store.getTasks(projectId);
1553
- const teams = await this.store.getTeams();
1775
+ if (projectId)
1776
+ await this.requireProjectAccess('project.read', projectId);
1777
+ const tasks = await this.getTasks(projectId);
1778
+ const teams = await this.getTeams();
1554
1779
  const project = projectId ? await this.store.getProject(projectId) : undefined;
1555
1780
  const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
1556
1781
  const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
@@ -1565,18 +1790,93 @@ export class CriticalPathEngine {
1565
1790
  schedule
1566
1791
  });
1567
1792
  }
1568
- async dispatchWebhook(event, payload) {
1793
+ // --- Webhooks ---
1794
+ /** Webhooks from `config.webhooks` (static, not editable) plus those in the store. */
1795
+ async listAllWebhooks() {
1796
+ const fromConfig = (this.config.webhooks ?? []).map((w, i) => ({
1797
+ ...w,
1798
+ id: `config_${i}`,
1799
+ createdAt: new Date(0).toISOString()
1800
+ }));
1801
+ return [...fromConfig, ...(await this.store.getWebhooks())];
1802
+ }
1803
+ toPublicWebhook({ secret, ...webhook }) {
1804
+ return { ...webhook, hasSecret: !!secret };
1805
+ }
1806
+ /** Webhooks in the actor's tenant, with secrets redacted. Requires `workspace.manage` on views. */
1807
+ async getWebhooks() {
1808
+ await this.requireWorkspaceAccess('workspace.manage');
1569
1809
  const webhooks = await this.store.getWebhooks();
1570
- const active = webhooks.filter((w) => w.active && w.events.includes(event));
1571
- for (const wh of active) {
1572
- fetch(wh.url, {
1573
- method: 'POST',
1574
- headers: { 'Content-Type': 'application/json' },
1575
- body: JSON.stringify({ event, timestamp: new Date().toISOString(), data: payload })
1576
- }).catch(() => {
1577
- // Silent catch for webhook errors in dev
1578
- });
1810
+ return webhooks.filter((w) => this.inActorTenant(w)).map((w) => this.toPublicWebhook(w));
1811
+ }
1812
+ async getWebhook(id) {
1813
+ await this.requireWorkspaceAccess('workspace.manage');
1814
+ const webhook = await this.store.getWebhook(id);
1815
+ return webhook && this.inActorTenant(webhook) ? this.toPublicWebhook(webhook) : null;
1816
+ }
1817
+ /**
1818
+ * Registers a webhook. The signing secret is returned only here; generate one by omitting
1819
+ * `secret`. Deliveries carry `X-CriticalPath-Signature` (see `verifyWebhookSignature`).
1820
+ */
1821
+ async createWebhook(input) {
1822
+ await this.requireWorkspaceAccess('workspace.manage');
1823
+ this.validateWebhookUrl(input.url);
1824
+ const secret = input.secret ?? generateWebhookSecret();
1825
+ const created = await this.store.addWebhook({
1826
+ name: input.name,
1827
+ url: input.url,
1828
+ events: input.events,
1829
+ secret,
1830
+ active: input.active ?? true,
1831
+ tenantId: this.actor?.tenantId
1832
+ });
1833
+ this.webhooks.invalidate();
1834
+ return { webhook: this.toPublicWebhook(created), secret };
1835
+ }
1836
+ async updateWebhook(id, updates) {
1837
+ if (!(await this.getWebhook(id)))
1838
+ return null;
1839
+ if (updates.url)
1840
+ this.validateWebhookUrl(updates.url);
1841
+ const { name, url, events, active, secret } = updates;
1842
+ const updated = await this.store.updateWebhook(id, Object.fromEntries(Object.entries({ name, url, events, active, secret }).filter(([, v]) => v !== undefined)));
1843
+ this.webhooks.invalidate();
1844
+ return updated ? this.toPublicWebhook(updated) : null;
1845
+ }
1846
+ async deleteWebhook(id) {
1847
+ if (!(await this.getWebhook(id)))
1848
+ return false;
1849
+ const deleted = await this.store.deleteWebhook(id);
1850
+ this.webhooks.invalidate();
1851
+ return deleted;
1852
+ }
1853
+ validateWebhookUrl(url) {
1854
+ try {
1855
+ assertWebhookUrl(url, this.config.webhookDelivery?.allowPrivateUrls);
1579
1856
  }
1857
+ catch (err) {
1858
+ throw new ValidationError(err.message);
1859
+ }
1860
+ }
1861
+ /** Finds the tenant an event belongs to, from the entity in its payload or its project. */
1862
+ async resolveEventTenant(event) {
1863
+ const p = event.payload;
1864
+ const owner = p.project ?? p.workflow ?? p.team;
1865
+ if (owner)
1866
+ return owner.tenantId ?? undefined;
1867
+ // Deleted projects and workflows carry their tenant in the payload.
1868
+ if ('tenantId' in p)
1869
+ return p.tenantId ?? undefined;
1870
+ const projectId = p.projectId ??
1871
+ p.task?.projectId ??
1872
+ p.deliverable?.projectId ??
1873
+ p.iteration?.projectId ??
1874
+ p.container?.projectId ??
1875
+ p.attachment?.projectId ??
1876
+ (await this.projectIdOfTask(p.taskId ?? p.comment?.taskId ?? p.attachment?.taskId ?? p.dependency?.taskId ?? p.timeEntry?.taskId));
1877
+ if (!projectId)
1878
+ return undefined;
1879
+ return (await this.store.getProject(projectId))?.tenantId ?? undefined;
1580
1880
  }
1581
1881
  }
1582
1882
  //# sourceMappingURL=index.js.map