@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
@@ -1,5 +1,7 @@
1
1
  import type {
2
+ Activity,
2
3
  Actor,
4
+ AuthorizationAction,
3
5
  CriticalPathConfig,
4
6
  Project,
5
7
  Task,
@@ -15,7 +17,8 @@ import type {
15
17
  PresignedUrlOptions,
16
18
  PresignedUploadResult,
17
19
  TimeEntry,
18
- WebhookEvent,
20
+ Webhook,
21
+ PublicWebhook,
19
22
  Workflow,
20
23
  CreateTaskInput,
21
24
  CreateProjectInput,
@@ -82,7 +85,10 @@ import {
82
85
  import { validateAttachmentUrl, AttachmentValidationError } from '../domain/entities.js';
83
86
  import { validateCustomFieldValues } from '../domain/custom-fields.js';
84
87
  import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
85
- import { ValidationError, NotFoundError } from '../domain/errors.js';
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';
86
92
  import { calculateCPM, type CPMOptions } from '../domain/cpm.js';
87
93
  import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
88
94
  import {
@@ -98,9 +104,13 @@ export class CriticalPathEngine {
98
104
  public readonly fileStorage?: FileStorageAdapter;
99
105
  public readonly plugins: PluginRegistry;
100
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;
101
109
  public readonly ready: Promise<void> = Promise.resolve();
102
110
  /** The identity mutations are attributed to. Set only on views returned by `withActor`. */
103
111
  public readonly actor?: Actor;
112
+ /** Set on internal views that keep the actor for attribution but skip authorization. */
113
+ private readonly authorizationBypassed?: boolean;
104
114
 
105
115
  constructor(config: CriticalPathConfig = {}) {
106
116
  this.config = config;
@@ -111,6 +121,14 @@ export class CriticalPathEngine {
111
121
  this.fileStorage = config.fileStorage;
112
122
  this.plugins = new PluginRegistry();
113
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));
114
132
 
115
133
  if (config.plugins) {
116
134
  for (const plugin of config.plugins) {
@@ -140,6 +158,112 @@ export class CriticalPathEngine {
140
158
  return this.actor?.userId ?? fallback;
141
159
  }
142
160
 
161
+ // --- Authorization (applies only to withActor views; the base engine is trusted) ---
162
+
163
+ /**
164
+ * True when calls must be checked: on a withActor view (not an internal elevated view) with a
165
+ * policy or a tenant to enforce. Otherwise there is nothing to check, so lookups are skipped.
166
+ */
167
+ private get enforcing(): boolean {
168
+ return !!this.actor && !this.authorizationBypassed && (!!this.config.authorize || !!this.actor.tenantId);
169
+ }
170
+
171
+ /** A view that keeps the actor for attribution but skips checks, for cascades already authorized. */
172
+ private elevated(): CriticalPathEngine {
173
+ if (!this.enforcing) return this;
174
+ const view = Object.create(this) as CriticalPathEngine;
175
+ Object.defineProperty(view, 'authorizationBypassed', { value: true });
176
+ return view;
177
+ }
178
+
179
+ private inActorTenant(entity: { tenantId?: string } | null | undefined): boolean {
180
+ const tenant = this.actor?.tenantId;
181
+ return !tenant || entity?.tenantId === tenant;
182
+ }
183
+
184
+ private async isAllowed(
185
+ action: AuthorizationAction,
186
+ project?: Project,
187
+ resource?: { type: 'task' | 'comment' | 'attachment'; ownerId?: string }
188
+ ): Promise<boolean> {
189
+ if (!this.enforcing) return true;
190
+ if (project && !this.inActorTenant(project)) return false;
191
+ const policy = this.config.authorize;
192
+ return policy ? await policy({ actor: this.actor!, action, project, resource }) : true;
193
+ }
194
+
195
+ /** The project if the actor may read it, otherwise null (callers report "not found"). */
196
+ private async readableProject(projectId: string | undefined): Promise<Project | null> {
197
+ if (!projectId) return null;
198
+ const project = await this.store.getProject(projectId);
199
+ if (!project) return null;
200
+ return (await this.isAllowed('project.read', project)) ? project : null;
201
+ }
202
+
203
+ private async canReadProject(projectId: string | undefined): Promise<boolean> {
204
+ if (!this.enforcing) return true;
205
+ return (await this.readableProject(projectId)) !== null;
206
+ }
207
+
208
+ /**
209
+ * Ensures the actor may perform `action` in `projectId`. Projects the actor cannot read (or
210
+ * that belong to another tenant) are reported as not found so their existence is not revealed.
211
+ */
212
+ private async requireProjectAccess(
213
+ action: AuthorizationAction,
214
+ projectId: string | undefined,
215
+ resource?: { type: 'task' | 'comment' | 'attachment'; ownerId?: string }
216
+ ): Promise<void> {
217
+ if (!this.enforcing) return;
218
+ const project = await this.readableProject(projectId);
219
+ if (!project) throw new NotFoundError(`Project "${projectId}" not found.`);
220
+ if (action !== 'project.read' && !(await this.isAllowed(action, project, resource))) {
221
+ throw new ForbiddenError(`Not allowed to ${action} in project "${projectId}".`);
222
+ }
223
+ }
224
+
225
+ private async requireWorkspaceAccess(action: 'project.create' | 'workspace.manage'): Promise<void> {
226
+ if (!(await this.isAllowed(action))) throw new ForbiddenError(`Not allowed to ${action}.`);
227
+ }
228
+
229
+ /** Keeps only items in projects the actor may read. Items with no project are hidden on views. */
230
+ private async filterReadable<T>(
231
+ items: T[],
232
+ projectIdOf: (item: T) => string | undefined | Promise<string | undefined>
233
+ ): Promise<T[]> {
234
+ if (!this.enforcing) return items;
235
+ const readable = new Map<string, boolean>();
236
+ const result: T[] = [];
237
+ for (const item of items) {
238
+ const projectId = await projectIdOf(item);
239
+ if (!projectId) continue;
240
+ if (!readable.has(projectId)) readable.set(projectId, await this.canReadProject(projectId));
241
+ if (readable.get(projectId)) result.push(item);
242
+ }
243
+ return result;
244
+ }
245
+
246
+ private async projectIdOfTask(taskId: string | undefined): Promise<string | undefined> {
247
+ return taskId ? (await this.store.getTask(taskId))?.projectId : undefined;
248
+ }
249
+
250
+ private async projectIdOfComment(commentId: string | undefined): Promise<string | undefined> {
251
+ if (!commentId) return undefined;
252
+ const comment = await this.store.getComment(commentId);
253
+ return this.projectIdOfTask(comment?.taskId);
254
+ }
255
+
256
+ private async projectIdOfAttachment(a: { projectId?: string; taskId?: string; commentId?: string }): Promise<string | undefined> {
257
+ return a.projectId ?? (await this.projectIdOfTask(a.taskId)) ?? (await this.projectIdOfComment(a.commentId));
258
+ }
259
+
260
+ /** Strips fields only the engine may set (tenancy) from updates made through a view. */
261
+ private withoutTenant<T extends { tenantId?: string }>(updates: T): T {
262
+ if (!this.actor || !('tenantId' in updates)) return updates;
263
+ const { tenantId: _ignored, ...rest } = updates;
264
+ return rest as T;
265
+ }
266
+
143
267
  private async seedInitialData(data: NonNullable<CriticalPathConfig['initialData']>): Promise<void> {
144
268
  if (data.workflows) {
145
269
  for (const wf of data.workflows) {
@@ -180,15 +304,18 @@ export class CriticalPathEngine {
180
304
 
181
305
  // --- Workflows ---
182
306
  async getWorkflows(): Promise<Workflow[]> {
183
- return this.store.getWorkflows();
307
+ const workflows = await this.store.getWorkflows();
308
+ return workflows.filter((w) => this.inActorTenant(w));
184
309
  }
185
310
 
186
311
  async getWorkflow(id: string): Promise<Workflow | null> {
187
- return this.store.getWorkflow(id);
312
+ const workflow = await this.store.getWorkflow(id);
313
+ return workflow && this.inActorTenant(workflow) ? workflow : null;
188
314
  }
189
315
 
190
316
  async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
191
- const created = await this.store.createWorkflow(workflow);
317
+ await this.requireWorkspaceAccess('workspace.manage');
318
+ const created = await this.store.createWorkflow({ ...workflow, tenantId: this.actor ? this.actor.tenantId : workflow.tenantId });
192
319
  const now = new Date().toISOString();
193
320
 
194
321
  const event: WorkflowCreatedEvent = {
@@ -206,13 +333,14 @@ export class CriticalPathEngine {
206
333
  action: 'workflow.created',
207
334
  details: { name: created.name }
208
335
  });
209
- this.dispatchWebhook('workflow.created', { workflow: created });
210
336
  return created;
211
337
  }
212
338
 
213
339
  async updateWorkflow(id: string, updates: Partial<Workflow>): Promise<Workflow | null> {
214
- const existing = await this.store.getWorkflow(id);
340
+ const existing = await this.getWorkflow(id);
215
341
  if (!existing) return null;
342
+ await this.requireWorkspaceAccess('workspace.manage');
343
+ updates = this.withoutTenant(updates);
216
344
 
217
345
  const updated = await this.store.updateWorkflow(id, updates);
218
346
  if (updated) {
@@ -232,14 +360,14 @@ export class CriticalPathEngine {
232
360
  action: 'workflow.updated',
233
361
  details: { name: updated.name }
234
362
  });
235
- this.dispatchWebhook('workflow.updated', { workflow: updated, previous: existing });
236
363
  }
237
364
  return updated;
238
365
  }
239
366
 
240
367
  async deleteWorkflow(id: string): Promise<boolean> {
241
- const existing = await this.store.getWorkflow(id);
368
+ const existing = await this.getWorkflow(id);
242
369
  if (!existing) return false;
370
+ await this.requireWorkspaceAccess('workspace.manage');
243
371
 
244
372
  const deleted = await this.store.deleteWorkflow(id);
245
373
  if (deleted) {
@@ -250,7 +378,7 @@ export class CriticalPathEngine {
250
378
  aggregateId: id,
251
379
  aggregateType: 'Workflow',
252
380
  occurredAt: now,
253
- payload: { workflowId: id, name: existing.name }
381
+ payload: { workflowId: id, name: existing.name, tenantId: existing.tenantId }
254
382
  };
255
383
  await this.events.publish(event);
256
384
 
@@ -259,7 +387,6 @@ export class CriticalPathEngine {
259
387
  action: 'workflow.deleted',
260
388
  details: { name: existing.name }
261
389
  });
262
- this.dispatchWebhook('workflow.deleted', { workflowId: id, name: existing.name });
263
390
  }
264
391
  return deleted;
265
392
  }
@@ -272,7 +399,8 @@ export class CriticalPathEngine {
272
399
  if (wf) return wf;
273
400
  }
274
401
 
275
- const workflows = await this.store.getWorkflows();
402
+ // Only fall back to workflows from the project's own tenant.
403
+ const workflows = (await this.store.getWorkflows()).filter((w) => w.tenantId === project?.tenantId);
276
404
  const defaultWf = workflows.find((w) => w.isDefault);
277
405
  if (defaultWf) return defaultWf;
278
406
  if (workflows.length > 0) return workflows[0];
@@ -281,21 +409,21 @@ export class CriticalPathEngine {
281
409
  }
282
410
 
283
411
  async getAllowedTaskTransitions(taskId: string): Promise<string[]> {
284
- const task = await this.store.getTask(taskId);
412
+ const task = await this.getTask(taskId);
285
413
  if (!task) return [];
286
414
  const workflow = await this.resolveProjectWorkflow(task.projectId);
287
415
  return getAllowedTransitions(workflow || undefined, task.status);
288
416
  }
289
417
 
290
418
  async getAllowedNextTaskTransitions(taskId: string): Promise<string[]> {
291
- const task = await this.store.getTask(taskId);
419
+ const task = await this.getTask(taskId);
292
420
  if (!task) return [];
293
421
  const workflow = await this.resolveProjectWorkflow(task.projectId);
294
422
  return getAllowedNextStatuses(workflow || undefined, task.status);
295
423
  }
296
424
 
297
425
  async getAllowedPreviousTaskTransitions(taskId: string): Promise<string[]> {
298
- const task = await this.store.getTask(taskId);
426
+ const task = await this.getTask(taskId);
299
427
  if (!task) return [];
300
428
  const workflow = await this.resolveProjectWorkflow(task.projectId);
301
429
  return getAllowedPreviousStatuses(workflow || undefined, task.status);
@@ -303,15 +431,24 @@ export class CriticalPathEngine {
303
431
 
304
432
  // --- Projects ---
305
433
  async getProjects(): Promise<Project[]> {
306
- return this.store.getProjects();
434
+ const projects = await this.store.getProjects();
435
+ return this.filterReadable(projects, (p) => p.id);
307
436
  }
308
437
 
309
438
  async getProject(id: string): Promise<Project | null> {
310
- return this.store.getProject(id);
439
+ if (!this.enforcing) return this.store.getProject(id);
440
+ return this.readableProject(id);
311
441
  }
312
442
 
313
443
  async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
314
- const created = await this.store.createProject(project);
444
+ await this.requireWorkspaceAccess('project.create');
445
+ // Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
446
+ const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
447
+ // The creator administers the project they create.
448
+ if (this.actor && this.actor.userId !== 'anonymous' && !input.members?.some((m) => m.userId === this.actor!.userId)) {
449
+ input.members = [...(input.members ?? []), { userId: this.actor.userId, role: 'admin' }];
450
+ }
451
+ const created = await this.store.createProject(input);
315
452
  const now = new Date().toISOString();
316
453
 
317
454
  const event: ProjectCreatedEvent = {
@@ -330,13 +467,15 @@ export class CriticalPathEngine {
330
467
  action: 'project.created',
331
468
  details: { name: created.name, key: created.key }
332
469
  });
333
- this.dispatchWebhook('project.created', { project: created });
334
470
  return created;
335
471
  }
336
472
 
337
473
  async updateProject(id: string, updates: Partial<Project>): Promise<Project | null> {
338
- const existing = await this.store.getProject(id);
474
+ const existing = await this.getProject(id);
339
475
  if (!existing) return null;
476
+ await this.requireProjectAccess('project.update', id);
477
+ if ('members' in updates) await this.requireProjectAccess('project.manage_members', id);
478
+ updates = this.withoutTenant(updates);
340
479
 
341
480
  const updated = await this.store.updateProject(id, updates);
342
481
  if (updated) {
@@ -357,7 +496,6 @@ export class CriticalPathEngine {
357
496
  action: 'project.updated',
358
497
  details: { name: updated.name }
359
498
  });
360
- this.dispatchWebhook('project.updated', { project: updated });
361
499
  }
362
500
  return updated;
363
501
  }
@@ -367,13 +505,16 @@ export class CriticalPathEngine {
367
505
  * hooks run and `task.deleted` events fire before `project.deleted` is published.
368
506
  */
369
507
  async deleteProject(id: string): Promise<boolean> {
370
- const existing = await this.store.getProject(id);
508
+ const existing = await this.getProject(id);
371
509
  if (!existing) return false;
510
+ await this.requireProjectAccess('project.delete', id);
511
+ // Deleting the project implies deleting its tasks; skip per-task checks for the cascade.
512
+ const cascade = this.elevated();
372
513
 
373
514
  const tasks = await this.store.getTasks(id);
374
515
  const deletedTaskIds: string[] = [];
375
516
  for (const task of tasks) {
376
- if (await this.deleteTask(task.id)) {
517
+ if (await cascade.deleteTask(task.id)) {
377
518
  deletedTaskIds.push(task.id);
378
519
  }
379
520
  }
@@ -387,7 +528,7 @@ export class CriticalPathEngine {
387
528
  aggregateId: id,
388
529
  aggregateType: 'Project',
389
530
  occurredAt: now,
390
- payload: { projectId: id, name: existing.name, deletedTaskIds }
531
+ payload: { projectId: id, name: existing.name, deletedTaskIds, tenantId: existing.tenantId }
391
532
  };
392
533
  await this.events.publish(event);
393
534
 
@@ -397,21 +538,25 @@ export class CriticalPathEngine {
397
538
  action: 'project.deleted',
398
539
  details: { name: existing.name, deletedTaskCount: deletedTaskIds.length }
399
540
  });
400
- this.dispatchWebhook('project.deleted', { projectId: id, name: existing.name, deletedTaskIds });
401
541
  }
402
542
  return deleted;
403
543
  }
404
544
 
405
545
  // --- Tasks ---
406
546
  async getTasks(projectId?: string): Promise<Task[]> {
407
- return this.store.getTasks(projectId);
547
+ if (projectId && !(await this.canReadProject(projectId))) return [];
548
+ const tasks = await this.store.getTasks(projectId);
549
+ return projectId ? tasks : this.filterReadable(tasks, (t) => t.projectId);
408
550
  }
409
551
 
410
552
  async getTask(id: string): Promise<Task | null> {
411
- return this.store.getTask(id);
553
+ const task = await this.store.getTask(id);
554
+ if (!task) return null;
555
+ return (await this.canReadProject(task.projectId)) ? task : null;
412
556
  }
413
557
 
414
558
  async createTask(taskInput: CreateTaskInput): Promise<Task> {
559
+ await this.requireProjectAccess('task.create', taskInput.projectId);
415
560
  if (this.actor && !taskInput.reporterId) {
416
561
  taskInput = { ...taskInput, reporterId: this.actor.userId };
417
562
  }
@@ -505,7 +650,6 @@ export class CriticalPathEngine {
505
650
  details: { title: created.title, status: created.status }
506
651
  });
507
652
 
508
- this.dispatchWebhook('task.created', { task: created });
509
653
  return created;
510
654
  }
511
655
 
@@ -519,8 +663,9 @@ export class CriticalPathEngine {
519
663
  actor?: { userId: string; username?: string; actorType?: string };
520
664
  }
521
665
  ): Promise<Task | null> {
522
- const existing = await this.store.getTask(id);
666
+ const existing = await this.getTask(id);
523
667
  if (!existing) return null;
668
+ await this.requireProjectAccess('task.update', existing.projectId);
524
669
 
525
670
  // Identity comes from trusted options or the withActor view, never from the update payload.
526
671
  options = options ?? (this.actor ? { actor: this.actor } : undefined);
@@ -787,10 +932,6 @@ export class CriticalPathEngine {
787
932
  action: 'task.blocked',
788
933
  details: { reason: blockedReason, ...(actorName ? { actorName } : {}) }
789
934
  });
790
- this.dispatchWebhook('task.blocked', {
791
- task: updated,
792
- reason: blockedReason
793
- });
794
935
  } else if (wasBlocked && !isNowBlocked) {
795
936
  const unblockedEvent: TaskUnblockedEvent = {
796
937
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -813,9 +954,6 @@ export class CriticalPathEngine {
813
954
  action: 'task.unblocked',
814
955
  details: { ...(actorName ? { actorName } : {}) }
815
956
  });
816
- this.dispatchWebhook('task.unblocked', {
817
- task: updated
818
- });
819
957
  }
820
958
 
821
959
  await this.store.logActivity({
@@ -830,10 +968,6 @@ export class CriticalPathEngine {
830
968
  }
831
969
  });
832
970
 
833
- this.dispatchWebhook(isStatusChange ? 'task.status_changed' : 'task.updated', {
834
- task: updated,
835
- previous: existing
836
- });
837
971
 
838
972
  return updated;
839
973
  }
@@ -904,10 +1038,6 @@ export class CriticalPathEngine {
904
1038
  details: { unblockedByTaskId: completedTask.id }
905
1039
  });
906
1040
 
907
- this.dispatchWebhook('task.unblocked', {
908
- task: updatedDownstream,
909
- upstreamTaskId: completedTask.id
910
- });
911
1041
  }
912
1042
  }
913
1043
  } catch (err) {
@@ -916,8 +1046,9 @@ export class CriticalPathEngine {
916
1046
  }
917
1047
 
918
1048
  async deleteTask(id: string): Promise<boolean> {
919
- const existing = await this.store.getTask(id);
1049
+ const existing = await this.getTask(id);
920
1050
  if (!existing) return false;
1051
+ await this.requireProjectAccess('task.delete', existing.projectId);
921
1052
 
922
1053
  await this.plugins.runBeforeTaskDelete(id);
923
1054
  const deleted = await this.store.deleteTask(id);
@@ -947,13 +1078,18 @@ export class CriticalPathEngine {
947
1078
  action: 'task.deleted',
948
1079
  details: { title: existing.title }
949
1080
  });
950
- this.dispatchWebhook('task.deleted', { taskId: id, title: existing.title });
951
1081
  }
952
1082
  return deleted;
953
1083
  }
954
1084
 
955
1085
  // --- Task Dependencies & Graph ---
956
1086
  async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
1087
+ if (this.enforcing) {
1088
+ const [task, upstream] = await Promise.all([this.getTask(dep.taskId), this.getTask(dep.dependsOnTaskId)]);
1089
+ if (!task) throw new NotFoundError(`Task "${dep.taskId}" not found.`);
1090
+ if (!upstream) throw new NotFoundError(`Task "${dep.dependsOnTaskId}" not found.`);
1091
+ await this.requireProjectAccess('task.update', task.projectId);
1092
+ }
957
1093
  // Enforce Directed Acyclic Graph (DAG) Invariant
958
1094
  const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
959
1095
  const cycleCheck = detectDependencyCycle(reachableDeps, dep);
@@ -1009,6 +1145,9 @@ export class CriticalPathEngine {
1009
1145
  }
1010
1146
 
1011
1147
  async getTaskDependencyGraph(taskId: string): Promise<TaskDependencyGraph> {
1148
+ if (this.enforcing && !(await this.getTask(taskId))) {
1149
+ return { taskId, upstreamTasks: [], downstreamTasks: [], dependencies: [] };
1150
+ }
1012
1151
  const dependencies = await this.store.getDependencies(taskId);
1013
1152
  const upstreamTaskIds = new Set<string>();
1014
1153
  const downstreamTaskIds = new Set<string>();
@@ -1022,11 +1161,11 @@ export class CriticalPathEngine {
1022
1161
  }
1023
1162
 
1024
1163
  const upstreamTasks = (
1025
- await Promise.all(Array.from(upstreamTaskIds).map((id) => this.store.getTask(id)))
1164
+ await Promise.all(Array.from(upstreamTaskIds).map((id) => this.getTask(id)))
1026
1165
  ).filter((t): t is Task => t !== null);
1027
1166
 
1028
1167
  const downstreamTasks = (
1029
- await Promise.all(Array.from(downstreamTaskIds).map((id) => this.store.getTask(id)))
1168
+ await Promise.all(Array.from(downstreamTaskIds).map((id) => this.getTask(id)))
1030
1169
  ).filter((t): t is Task => t !== null);
1031
1170
 
1032
1171
  return {
@@ -1064,7 +1203,11 @@ export class CriticalPathEngine {
1064
1203
  throw new ValidationError('Logged hours must be a positive number.');
1065
1204
  }
1066
1205
 
1067
- const task = await this.store.getTask(entry.taskId);
1206
+ const task = await this.getTask(entry.taskId);
1207
+ if (this.enforcing) {
1208
+ if (!task) throw new NotFoundError(`Task "${entry.taskId}" not found.`);
1209
+ await this.requireProjectAccess('time.log', task.projectId);
1210
+ }
1068
1211
  if (task) {
1069
1212
  const newLoggedHours = (task.loggedHours || 0) + entry.hours;
1070
1213
  const newActualHours = (task.actualHours || 0) + entry.hours;
@@ -1100,16 +1243,34 @@ export class CriticalPathEngine {
1100
1243
 
1101
1244
  // --- Comments ---
1102
1245
  async getComments(taskId: string): Promise<Comment[]> {
1246
+ if (this.enforcing && !(await this.getTask(taskId))) return [];
1103
1247
  return this.store.getComments(taskId);
1104
1248
  }
1105
1249
 
1106
1250
  async getComment(id: string): Promise<Comment | null> {
1107
- return this.store.getComment(id);
1251
+ const comment = await this.store.getComment(id);
1252
+ if (!comment) return null;
1253
+ return (await this.canReadProject(await this.projectIdOfTask(comment.taskId))) ? comment : null;
1254
+ }
1255
+
1256
+ async getActivities(filter?: { projectId?: string; taskId?: string }): Promise<Activity[]> {
1257
+ const activities = await this.store.getActivities(filter);
1258
+ return this.filterReadable(activities, (a) => a.projectId ?? this.projectIdOfTask(a.taskId));
1259
+ }
1260
+
1261
+ async getTimeEntries(taskId: string): Promise<TimeEntry[]> {
1262
+ if (this.enforcing && !(await this.getTask(taskId))) return [];
1263
+ return this.store.getTimeEntries(taskId);
1108
1264
  }
1109
1265
 
1110
1266
  async addComment(
1111
1267
  input: Omit<Comment, 'id' | 'createdAt' | 'updatedAt' | 'authorId'> & { authorId?: string }
1112
1268
  ): Promise<Comment> {
1269
+ if (this.enforcing) {
1270
+ const task = await this.getTask(input.taskId);
1271
+ if (!task) throw new NotFoundError(`Task "${input.taskId}" not found.`);
1272
+ await this.requireProjectAccess('comment.create', task.projectId);
1273
+ }
1113
1274
  const comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'> = this.actor
1114
1275
  ? { ...input, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' }
1115
1276
  : { ...input, authorId: input.authorId ?? 'system' };
@@ -1127,13 +1288,16 @@ export class CriticalPathEngine {
1127
1288
  };
1128
1289
  await this.events.publish(event);
1129
1290
 
1130
- this.dispatchWebhook('comment.created', { comment: created });
1131
1291
  return created;
1132
1292
  }
1133
1293
 
1134
1294
  async updateComment(id: string, updates: Partial<Comment>): Promise<Comment | null> {
1135
- const existing = await this.store.getComment(id);
1295
+ const existing = await this.getComment(id);
1136
1296
  if (!existing) return null;
1297
+ await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
1298
+ type: 'comment',
1299
+ ownerId: existing.authorId
1300
+ });
1137
1301
 
1138
1302
  const mentions = updates.mentions ?? (updates.content ? extractMentions(updates.content) : undefined);
1139
1303
  const toUpdate = mentions !== undefined ? { ...updates, mentions } : updates;
@@ -1152,13 +1316,16 @@ export class CriticalPathEngine {
1152
1316
  };
1153
1317
  await this.events.publish(event);
1154
1318
 
1155
- this.dispatchWebhook('comment.updated', { comment: updated });
1156
1319
  return updated;
1157
1320
  }
1158
1321
 
1159
1322
  async deleteComment(id: string): Promise<boolean> {
1160
- const existing = await this.store.getComment(id);
1323
+ const existing = await this.getComment(id);
1161
1324
  if (!existing) return false;
1325
+ await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
1326
+ type: 'comment',
1327
+ ownerId: existing.authorId
1328
+ });
1162
1329
 
1163
1330
  const deleted = await this.store.deleteComment(id);
1164
1331
  if (deleted) {
@@ -1173,15 +1340,15 @@ export class CriticalPathEngine {
1173
1340
  };
1174
1341
  await this.events.publish(event);
1175
1342
 
1176
- this.dispatchWebhook('comment.deleted', { commentId: id, taskId: existing.taskId });
1177
1343
  }
1178
1344
  return deleted;
1179
1345
  }
1180
1346
 
1181
1347
  async addCommentReaction(commentId: string, input: { emoji: string; userId?: string }): Promise<Comment | null> {
1182
1348
  const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
1183
- const existing = await this.store.getComment(commentId);
1349
+ const existing = await this.getComment(commentId);
1184
1350
  if (!existing) return null;
1351
+ await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
1185
1352
 
1186
1353
  let updated: Comment | null;
1187
1354
  if (this.store.addReaction) {
@@ -1220,14 +1387,14 @@ export class CriticalPathEngine {
1220
1387
  };
1221
1388
  await this.events.publish(event);
1222
1389
 
1223
- this.dispatchWebhook('comment.reaction.added', { comment: updated, reaction });
1224
1390
  return updated;
1225
1391
  }
1226
1392
 
1227
1393
  async removeCommentReaction(commentId: string, input: { emoji: string; userId?: string }): Promise<Comment | null> {
1228
1394
  const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
1229
- const existing = await this.store.getComment(commentId);
1395
+ const existing = await this.getComment(commentId);
1230
1396
  if (!existing) return null;
1397
+ await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
1231
1398
 
1232
1399
  let updated: Comment | null;
1233
1400
  if (this.store.removeReaction) {
@@ -1252,13 +1419,14 @@ export class CriticalPathEngine {
1252
1419
  };
1253
1420
  await this.events.publish(event);
1254
1421
 
1255
- this.dispatchWebhook('comment.reaction.removed', { comment: updated, reaction });
1256
1422
  return updated;
1257
1423
  }
1258
1424
 
1259
1425
  // --- Attachments ---
1260
1426
  async getAttachments(filter?: { taskId?: string; projectId?: string; commentId?: string; artifactType?: string }): Promise<Attachment[]> {
1261
- const attachments = await this.store.getAttachments(filter);
1427
+ const attachments = await this.filterReadable(await this.store.getAttachments(filter), (a) =>
1428
+ this.projectIdOfAttachment(a)
1429
+ );
1262
1430
  if (filter?.artifactType) {
1263
1431
  return attachments.filter(a => a.artifactType === filter.artifactType);
1264
1432
  }
@@ -1266,12 +1434,22 @@ export class CriticalPathEngine {
1266
1434
  }
1267
1435
 
1268
1436
  async getAttachment(id: string): Promise<Attachment | null> {
1269
- return this.store.getAttachment(id);
1437
+ const attachment = await this.store.getAttachment(id);
1438
+ if (!attachment) return null;
1439
+ return (await this.canReadProject(await this.projectIdOfAttachment(attachment))) ? attachment : null;
1440
+ }
1441
+
1442
+ private async requireAttachmentCreate(link: { projectId?: string; taskId?: string; commentId?: string }): Promise<void> {
1443
+ if (!this.enforcing) return;
1444
+ const projectId = await this.projectIdOfAttachment(link);
1445
+ if (!projectId) throw new ValidationError('Attachments must reference a project, task or comment.');
1446
+ await this.requireProjectAccess('attachment.create', projectId);
1270
1447
  }
1271
1448
 
1272
1449
  async createAttachment(
1273
1450
  input: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt' | 'uploaderId'> & { uploaderId?: string }
1274
1451
  ): Promise<Attachment> {
1452
+ await this.requireAttachmentCreate(input);
1275
1453
  const attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'> = this.actor
1276
1454
  ? { ...input, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' }
1277
1455
  : { ...input, uploaderId: input.uploaderId ?? 'system' };
@@ -1289,13 +1467,16 @@ export class CriticalPathEngine {
1289
1467
  };
1290
1468
  await this.events.publish(event);
1291
1469
 
1292
- this.dispatchWebhook('attachment.created', { attachment: created });
1293
1470
  return created;
1294
1471
  }
1295
1472
 
1296
1473
  async deleteAttachment(id: string): Promise<boolean> {
1297
- const existing = await this.store.getAttachment(id);
1474
+ const existing = await this.getAttachment(id);
1298
1475
  if (!existing) return false;
1476
+ await this.requireProjectAccess('attachment.delete', await this.projectIdOfAttachment(existing), {
1477
+ type: 'attachment',
1478
+ ownerId: existing.uploaderId
1479
+ });
1299
1480
 
1300
1481
  if (existing.storageKey && this.fileStorage) {
1301
1482
  try {
@@ -1314,11 +1495,15 @@ export class CriticalPathEngine {
1314
1495
  aggregateId: id,
1315
1496
  aggregateType: 'Attachment',
1316
1497
  occurredAt: now,
1317
- payload: { attachmentId: id, storageKey: existing.storageKey, url: existing.url }
1498
+ payload: {
1499
+ attachmentId: id,
1500
+ storageKey: existing.storageKey,
1501
+ url: existing.url,
1502
+ projectId: await this.projectIdOfAttachment(existing)
1503
+ }
1318
1504
  };
1319
1505
  await this.events.publish(event);
1320
1506
 
1321
- this.dispatchWebhook('attachment.deleted', { attachmentId: id, taskId: existing.taskId, projectId: existing.projectId });
1322
1507
  }
1323
1508
  return deleted;
1324
1509
  }
@@ -1337,6 +1522,8 @@ export class CriticalPathEngine {
1337
1522
  if (!this.fileStorage) {
1338
1523
  throw new Error('No FileStorageAdapter configured in CriticalPathEngine. Pass "fileStorage" in config to enable direct file uploads.');
1339
1524
  }
1525
+ // Check before uploading so denied requests do not leave orphaned files behind.
1526
+ await this.requireAttachmentCreate(input);
1340
1527
 
1341
1528
  const uploadResult = await this.fileStorage.upload({
1342
1529
  filename: input.filename,
@@ -1394,24 +1581,41 @@ export class CriticalPathEngine {
1394
1581
  throw new Error(`Cannot read attachment "${id}": no valid storageKey or URL found.`);
1395
1582
  }
1396
1583
 
1397
- async getPresignedAttachmentUploadUrl(options: PresignedUrlOptions): Promise<PresignedUploadResult> {
1584
+ async getPresignedAttachmentUploadUrl(
1585
+ options: PresignedUrlOptions & { projectId?: string }
1586
+ ): Promise<PresignedUploadResult> {
1398
1587
  if (!this.fileStorage || !this.fileStorage.getPresignedUploadUrl) {
1399
1588
  throw new Error('Presigned uploads are not supported by the configured FileStorageAdapter.');
1400
1589
  }
1401
- return this.fileStorage.getPresignedUploadUrl(options);
1590
+ const { projectId, ...presign } = options;
1591
+ if (this.enforcing) {
1592
+ // Scoped callers may only write under their project's prefix.
1593
+ if (!projectId) throw new ValidationError('projectId is required for presigned uploads.');
1594
+ await this.requireProjectAccess('attachment.create', projectId);
1595
+ const prefix = `projects/${projectId}/`;
1596
+ const key = presign.storageKey.replace(/^\/+/, '');
1597
+ if (key.split('/').some((segment) => segment === '..' || segment === '.')) {
1598
+ throw new ValidationError('storageKey must not contain "." or ".." segments.');
1599
+ }
1600
+ presign.storageKey = key.startsWith(prefix) ? key : prefix + key;
1601
+ }
1602
+ return this.fileStorage.getPresignedUploadUrl(presign);
1402
1603
  }
1403
1604
 
1404
1605
  // --- Teams ---
1405
1606
  async getTeams(): Promise<Team[]> {
1406
- return this.store.getTeams();
1607
+ const teams = await this.store.getTeams();
1608
+ return teams.filter((t) => this.inActorTenant(t));
1407
1609
  }
1408
1610
 
1409
1611
  async getTeam(id: string): Promise<Team | null> {
1410
- return this.store.getTeam(id);
1612
+ const team = await this.store.getTeam(id);
1613
+ return team && this.inActorTenant(team) ? team : null;
1411
1614
  }
1412
1615
 
1413
1616
  async createTeam(team: Omit<Team, 'id' | 'createdAt' | 'updatedAt'>): Promise<Team> {
1414
- const created = await this.store.createTeam(team);
1617
+ await this.requireWorkspaceAccess('workspace.manage');
1618
+ const created = await this.store.createTeam({ ...team, tenantId: this.actor ? this.actor.tenantId : team.tenantId });
1415
1619
  const now = new Date().toISOString();
1416
1620
 
1417
1621
  const event: TeamCreatedEvent = {
@@ -1424,28 +1628,35 @@ export class CriticalPathEngine {
1424
1628
  };
1425
1629
  await this.events.publish(event);
1426
1630
 
1427
- this.dispatchWebhook('team.created', { team: created });
1428
1631
  return created;
1429
1632
  }
1430
1633
 
1431
1634
  async updateTeam(id: string, updates: Partial<Team>): Promise<Team | null> {
1432
- return this.store.updateTeam(id, updates);
1635
+ if (!(await this.getTeam(id))) return null;
1636
+ await this.requireWorkspaceAccess('workspace.manage');
1637
+ return this.store.updateTeam(id, this.withoutTenant(updates));
1433
1638
  }
1434
1639
 
1435
1640
  async deleteTeam(id: string): Promise<boolean> {
1641
+ if (!(await this.getTeam(id))) return false;
1642
+ await this.requireWorkspaceAccess('workspace.manage');
1436
1643
  return this.store.deleteTeam(id);
1437
1644
  }
1438
1645
 
1439
1646
  // --- Containers ---
1440
1647
  async getContainers(projectId: string): Promise<TaskContainer[]> {
1648
+ if (!(await this.canReadProject(projectId))) return [];
1441
1649
  return this.store.getContainers(projectId);
1442
1650
  }
1443
1651
 
1444
1652
  async getContainer(id: string): Promise<TaskContainer | null> {
1445
- return this.store.getContainer(id);
1653
+ const container = await this.store.getContainer(id);
1654
+ if (!container) return null;
1655
+ return (await this.canReadProject(container.projectId)) ? container : null;
1446
1656
  }
1447
1657
 
1448
1658
  async createContainer(container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'>): Promise<TaskContainer> {
1659
+ await this.requireProjectAccess('plan.manage', container.projectId);
1449
1660
  const created = await this.store.createContainer(container);
1450
1661
  const now = new Date().toISOString();
1451
1662
 
@@ -1459,28 +1670,37 @@ export class CriticalPathEngine {
1459
1670
  };
1460
1671
  await this.events.publish(event);
1461
1672
 
1462
- this.dispatchWebhook('container.created', { container: created });
1463
1673
  return created;
1464
1674
  }
1465
1675
 
1466
1676
  async updateContainer(id: string, updates: Partial<TaskContainer>): Promise<TaskContainer | null> {
1677
+ const existing = await this.getContainer(id);
1678
+ if (!existing) return null;
1679
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1467
1680
  return this.store.updateContainer(id, updates);
1468
1681
  }
1469
1682
 
1470
1683
  async deleteContainer(id: string): Promise<boolean> {
1684
+ const existing = await this.getContainer(id);
1685
+ if (!existing) return false;
1686
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1471
1687
  return this.store.deleteContainer(id);
1472
1688
  }
1473
1689
 
1474
1690
  // --- Deliverables ---
1475
1691
  async getDeliverables(projectId: string): Promise<Deliverable[]> {
1692
+ if (!(await this.canReadProject(projectId))) return [];
1476
1693
  return this.store.getDeliverables(projectId);
1477
1694
  }
1478
1695
 
1479
1696
  async getDeliverable(id: string): Promise<Deliverable | null> {
1480
- return this.store.getDeliverable(id);
1697
+ const deliverable = await this.store.getDeliverable(id);
1698
+ if (!deliverable) return null;
1699
+ return (await this.canReadProject(deliverable.projectId)) ? deliverable : null;
1481
1700
  }
1482
1701
 
1483
1702
  async createDeliverable(input: CreateDeliverableInput): Promise<Deliverable> {
1703
+ await this.requireProjectAccess('plan.manage', input.projectId);
1484
1704
  const project = await this.store.getProject(input.projectId);
1485
1705
  if (project?.customFieldDefinitions && input.customFields) {
1486
1706
  validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
@@ -1510,13 +1730,13 @@ export class CriticalPathEngine {
1510
1730
  action: 'deliverable.created',
1511
1731
  details: { title: created.title, format: created.format }
1512
1732
  });
1513
- this.dispatchWebhook('deliverable.created', { deliverable: created });
1514
1733
  return created;
1515
1734
  }
1516
1735
 
1517
1736
  async updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null> {
1518
- const existing = await this.store.getDeliverable(id);
1737
+ const existing = await this.getDeliverable(id);
1519
1738
  if (!existing) return null;
1739
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1520
1740
 
1521
1741
  if (updates.status && updates.status === 'delivered' && !existing.deliveredAt && !updates.deliveredAt) {
1522
1742
  updates.deliveredAt = new Date().toISOString();
@@ -1540,11 +1760,6 @@ export class CriticalPathEngine {
1540
1760
  }
1541
1761
  };
1542
1762
  await this.events.publish(statusEvent);
1543
- this.dispatchWebhook('deliverable.status_changed', {
1544
- deliverable: updated,
1545
- previousStatus: existing.status,
1546
- newStatus: updated.status
1547
- });
1548
1763
  }
1549
1764
 
1550
1765
  const updateEvent: DeliverableUpdatedEvent = {
@@ -1563,14 +1778,14 @@ export class CriticalPathEngine {
1563
1778
  action: 'deliverable.updated',
1564
1779
  details: { title: updated.title, status: updated.status }
1565
1780
  });
1566
- this.dispatchWebhook('deliverable.updated', { deliverable: updated });
1567
1781
  }
1568
1782
  return updated;
1569
1783
  }
1570
1784
 
1571
1785
  async deleteDeliverable(id: string): Promise<boolean> {
1572
- const existing = await this.store.getDeliverable(id);
1786
+ const existing = await this.getDeliverable(id);
1573
1787
  if (!existing) return false;
1788
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1574
1789
 
1575
1790
  const deleted = await this.store.deleteDeliverable(id);
1576
1791
  if (deleted) {
@@ -1591,13 +1806,12 @@ export class CriticalPathEngine {
1591
1806
  action: 'deliverable.deleted',
1592
1807
  details: { title: existing.title }
1593
1808
  });
1594
- this.dispatchWebhook('deliverable.deleted', { deliverableId: id, projectId: existing.projectId });
1595
1809
  }
1596
1810
  return deleted;
1597
1811
  }
1598
1812
 
1599
1813
  async getDeliverableSummary(deliverableId: string): Promise<DeliverableSummary | null> {
1600
- const deliverable = await this.store.getDeliverable(deliverableId);
1814
+ const deliverable = await this.getDeliverable(deliverableId);
1601
1815
  if (!deliverable) return null;
1602
1816
 
1603
1817
  const project = await this.store.getProject(deliverable.projectId);
@@ -1645,14 +1859,18 @@ export class CriticalPathEngine {
1645
1859
 
1646
1860
  // --- Iterations ---
1647
1861
  async getIterations(projectId: string): Promise<Iteration[]> {
1862
+ if (!(await this.canReadProject(projectId))) return [];
1648
1863
  return this.store.getIterations(projectId);
1649
1864
  }
1650
1865
 
1651
1866
  async getIteration(id: string): Promise<Iteration | null> {
1652
- return this.store.getIteration(id);
1867
+ const iteration = await this.store.getIteration(id);
1868
+ if (!iteration) return null;
1869
+ return (await this.canReadProject(iteration.projectId)) ? iteration : null;
1653
1870
  }
1654
1871
 
1655
1872
  async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
1873
+ await this.requireProjectAccess('plan.manage', iteration.projectId);
1656
1874
  const created = await this.store.createIteration(iteration);
1657
1875
  if (created.status === 'active') {
1658
1876
  const now = new Date().toISOString();
@@ -1665,14 +1883,14 @@ export class CriticalPathEngine {
1665
1883
  payload: { iteration: created }
1666
1884
  };
1667
1885
  await this.events.publish(event);
1668
- this.dispatchWebhook('iteration.started', { iteration: created });
1669
1886
  }
1670
1887
  return created;
1671
1888
  }
1672
1889
 
1673
1890
  async updateIteration(id: string, updates: Partial<Iteration>): Promise<Iteration | null> {
1674
- const existing = await this.store.getIteration(id);
1891
+ const existing = await this.getIteration(id);
1675
1892
  if (!existing) return null;
1893
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1676
1894
 
1677
1895
  const updated = await this.store.updateIteration(id, updates);
1678
1896
  if (updated) {
@@ -1687,7 +1905,6 @@ export class CriticalPathEngine {
1687
1905
  payload: { iteration: updated }
1688
1906
  };
1689
1907
  await this.events.publish(event);
1690
- this.dispatchWebhook('iteration.started', { iteration: updated });
1691
1908
  } else if (existing.status !== 'completed' && updated.status === 'completed') {
1692
1909
  const event: IterationCompletedEvent = {
1693
1910
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -1698,13 +1915,15 @@ export class CriticalPathEngine {
1698
1915
  payload: { iteration: updated }
1699
1916
  };
1700
1917
  await this.events.publish(event);
1701
- this.dispatchWebhook('iteration.completed', { iteration: updated });
1702
1918
  }
1703
1919
  }
1704
1920
  return updated;
1705
1921
  }
1706
1922
 
1707
1923
  async deleteIteration(id: string): Promise<boolean> {
1924
+ const existing = await this.getIteration(id);
1925
+ if (!existing) return false;
1926
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1708
1927
  return this.store.deleteIteration(id);
1709
1928
  }
1710
1929
 
@@ -1713,6 +1932,7 @@ export class CriticalPathEngine {
1713
1932
  projectId: string,
1714
1933
  options: CPMOptions = {}
1715
1934
  ): Promise<CriticalPathAnalysis> {
1935
+ await this.requireProjectAccess('project.read', projectId);
1716
1936
  const project = await this.store.getProject(projectId);
1717
1937
  const tasks = await this.store.getTasks(projectId);
1718
1938
  const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
@@ -1735,6 +1955,7 @@ export class CriticalPathEngine {
1735
1955
  projectId: string,
1736
1956
  options: TimelineLadderOptions = {}
1737
1957
  ): Promise<TimelineLadder> {
1958
+ await this.requireProjectAccess('project.read', projectId);
1738
1959
  const project = await this.store.getProject(projectId);
1739
1960
  if (!project) {
1740
1961
  throw new NotFoundError(`Project with ID "${projectId}" not found.`);
@@ -1791,7 +2012,7 @@ export class CriticalPathEngine {
1791
2012
  }
1792
2013
 
1793
2014
  async getTaskLadder(taskId: string): Promise<TaskLadderView | null> {
1794
- const task = await this.store.getTask(taskId);
2015
+ const task = await this.getTask(taskId);
1795
2016
  if (!task) return null;
1796
2017
 
1797
2018
  const project = await this.store.getProject(task.projectId);
@@ -1815,7 +2036,7 @@ export class CriticalPathEngine {
1815
2036
  }
1816
2037
 
1817
2038
  async getTaskMetrics(taskId: string, options?: MetricOptions): Promise<TaskMetrics | null> {
1818
- const task = await this.store.getTask(taskId);
2039
+ const task = await this.getTask(taskId);
1819
2040
  if (!task) return null;
1820
2041
 
1821
2042
  const [activities, timeEntries, project, workflow] = await Promise.all([
@@ -1834,7 +2055,7 @@ export class CriticalPathEngine {
1834
2055
  }
1835
2056
 
1836
2057
  async getTaskProgressHistory(taskId: string, options?: MetricOptions): Promise<TaskProgressHistory | null> {
1837
- const task = await this.store.getTask(taskId);
2058
+ const task = await this.getTask(taskId);
1838
2059
  if (!task) return null;
1839
2060
 
1840
2061
  const [activities, project, workflow] = await Promise.all([
@@ -1855,8 +2076,9 @@ export class CriticalPathEngine {
1855
2076
  projectId?: string,
1856
2077
  options: WorkloadDistributionOptions = {}
1857
2078
  ): Promise<WorkloadDistribution> {
1858
- const tasks = await this.store.getTasks(projectId);
1859
- const teams = await this.store.getTeams();
2079
+ if (projectId) await this.requireProjectAccess('project.read', projectId);
2080
+ const tasks = await this.getTasks(projectId);
2081
+ const teams = await this.getTeams();
1860
2082
  const project = projectId ? await this.store.getProject(projectId) : undefined;
1861
2083
  const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
1862
2084
 
@@ -1877,19 +2099,108 @@ export class CriticalPathEngine {
1877
2099
  );
1878
2100
  }
1879
2101
 
1880
- private async dispatchWebhook(event: WebhookEvent, payload: Record<string, unknown>): Promise<void> {
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');
1881
2121
  const webhooks = await this.store.getWebhooks();
1882
- const active = webhooks.filter((w) => w.active && w.events.includes(event));
1883
-
1884
- for (const wh of active) {
1885
- fetch(wh.url, {
1886
- method: 'POST',
1887
- headers: { 'Content-Type': 'application/json' },
1888
- body: JSON.stringify({ event, timestamp: new Date().toISOString(), data: payload })
1889
- }).catch(() => {
1890
- // Silent catch for webhook errors in dev
1891
- });
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);
1892
2184
  }
1893
2185
  }
1894
- }
1895
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
+ }