@critical-path/core 0.24.0 → 0.26.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 (54) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +15 -3
  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/index.d.ts +1 -0
  15. package/dist/domain/index.d.ts.map +1 -1
  16. package/dist/domain/index.js +1 -0
  17. package/dist/domain/index.js.map +1 -1
  18. package/dist/engine/actor.test.js +1 -0
  19. package/dist/engine/actor.test.js.map +1 -1
  20. package/dist/engine/authorization.test.d.ts +2 -0
  21. package/dist/engine/authorization.test.d.ts.map +1 -0
  22. package/dist/engine/authorization.test.js +150 -0
  23. package/dist/engine/authorization.test.js.map +1 -0
  24. package/dist/engine/index.d.ts +49 -19
  25. package/dist/engine/index.d.ts.map +1 -1
  26. package/dist/engine/index.js +321 -87
  27. package/dist/engine/index.js.map +1 -1
  28. package/dist/index.test.js +1 -5
  29. package/dist/index.test.js.map +1 -1
  30. package/dist/schemas/index.d.ts +127 -130
  31. package/dist/schemas/index.d.ts.map +1 -1
  32. package/dist/schemas/index.js +41 -50
  33. package/dist/schemas/index.js.map +1 -1
  34. package/dist/schemas/schemas.test.js +10 -11
  35. package/dist/schemas/schemas.test.js.map +1 -1
  36. package/dist/store/sqlite.d.ts.map +1 -1
  37. package/dist/store/sqlite.js +20 -9
  38. package/dist/store/sqlite.js.map +1 -1
  39. package/dist/types/index.d.ts +45 -1
  40. package/dist/types/index.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/domain/authorization.ts +82 -0
  43. package/src/domain/entities.ts +2 -1
  44. package/src/domain/errors.ts +10 -0
  45. package/src/domain/index.ts +1 -0
  46. package/src/engine/actor.test.ts +2 -1
  47. package/src/engine/authorization.test.ts +191 -0
  48. package/src/engine/index.ts +318 -100
  49. package/src/index.test.ts +1 -5
  50. package/src/schemas/index.ts +67 -51
  51. package/src/schemas/schemas.test.ts +19 -17
  52. package/src/store/sqlite.ts +20 -6
  53. package/src/types/index.ts +66 -1
  54. 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,
@@ -82,7 +84,7 @@ import {
82
84
  import { validateAttachmentUrl, AttachmentValidationError } from '../domain/entities.js';
83
85
  import { validateCustomFieldValues } from '../domain/custom-fields.js';
84
86
  import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
85
- import { ValidationError, NotFoundError } from '../domain/errors.js';
87
+ import { ValidationError, NotFoundError, ForbiddenError } from '../domain/errors.js';
86
88
  import { calculateCPM, type CPMOptions } from '../domain/cpm.js';
87
89
  import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
88
90
  import {
@@ -101,6 +103,8 @@ export class CriticalPathEngine {
101
103
  public readonly ready: Promise<void> = Promise.resolve();
102
104
  /** The identity mutations are attributed to. Set only on views returned by `withActor`. */
103
105
  public readonly actor?: Actor;
106
+ /** Set on internal views that keep the actor for attribution but skip authorization. */
107
+ private readonly authorizationBypassed?: boolean;
104
108
 
105
109
  constructor(config: CriticalPathConfig = {}) {
106
110
  this.config = config;
@@ -140,6 +144,112 @@ export class CriticalPathEngine {
140
144
  return this.actor?.userId ?? fallback;
141
145
  }
142
146
 
147
+ // --- Authorization (applies only to withActor views; the base engine is trusted) ---
148
+
149
+ /**
150
+ * True when calls must be checked: on a withActor view (not an internal elevated view) with a
151
+ * policy or a tenant to enforce. Otherwise there is nothing to check, so lookups are skipped.
152
+ */
153
+ private get enforcing(): boolean {
154
+ return !!this.actor && !this.authorizationBypassed && (!!this.config.authorize || !!this.actor.tenantId);
155
+ }
156
+
157
+ /** A view that keeps the actor for attribution but skips checks, for cascades already authorized. */
158
+ private elevated(): CriticalPathEngine {
159
+ if (!this.enforcing) return this;
160
+ const view = Object.create(this) as CriticalPathEngine;
161
+ Object.defineProperty(view, 'authorizationBypassed', { value: true });
162
+ return view;
163
+ }
164
+
165
+ private inActorTenant(entity: { tenantId?: string } | null | undefined): boolean {
166
+ const tenant = this.actor?.tenantId;
167
+ return !tenant || entity?.tenantId === tenant;
168
+ }
169
+
170
+ private async isAllowed(
171
+ action: AuthorizationAction,
172
+ project?: Project,
173
+ resource?: { type: 'task' | 'comment' | 'attachment'; ownerId?: string }
174
+ ): Promise<boolean> {
175
+ if (!this.enforcing) return true;
176
+ if (project && !this.inActorTenant(project)) return false;
177
+ const policy = this.config.authorize;
178
+ return policy ? await policy({ actor: this.actor!, action, project, resource }) : true;
179
+ }
180
+
181
+ /** The project if the actor may read it, otherwise null (callers report "not found"). */
182
+ private async readableProject(projectId: string | undefined): Promise<Project | null> {
183
+ if (!projectId) return null;
184
+ const project = await this.store.getProject(projectId);
185
+ if (!project) return null;
186
+ return (await this.isAllowed('project.read', project)) ? project : null;
187
+ }
188
+
189
+ private async canReadProject(projectId: string | undefined): Promise<boolean> {
190
+ if (!this.enforcing) return true;
191
+ return (await this.readableProject(projectId)) !== null;
192
+ }
193
+
194
+ /**
195
+ * Ensures the actor may perform `action` in `projectId`. Projects the actor cannot read (or
196
+ * that belong to another tenant) are reported as not found so their existence is not revealed.
197
+ */
198
+ private async requireProjectAccess(
199
+ action: AuthorizationAction,
200
+ projectId: string | undefined,
201
+ resource?: { type: 'task' | 'comment' | 'attachment'; ownerId?: string }
202
+ ): Promise<void> {
203
+ if (!this.enforcing) return;
204
+ const project = await this.readableProject(projectId);
205
+ if (!project) throw new NotFoundError(`Project "${projectId}" not found.`);
206
+ if (action !== 'project.read' && !(await this.isAllowed(action, project, resource))) {
207
+ throw new ForbiddenError(`Not allowed to ${action} in project "${projectId}".`);
208
+ }
209
+ }
210
+
211
+ private async requireWorkspaceAccess(action: 'project.create' | 'workspace.manage'): Promise<void> {
212
+ if (!(await this.isAllowed(action))) throw new ForbiddenError(`Not allowed to ${action}.`);
213
+ }
214
+
215
+ /** Keeps only items in projects the actor may read. Items with no project are hidden on views. */
216
+ private async filterReadable<T>(
217
+ items: T[],
218
+ projectIdOf: (item: T) => string | undefined | Promise<string | undefined>
219
+ ): Promise<T[]> {
220
+ if (!this.enforcing) return items;
221
+ const readable = new Map<string, boolean>();
222
+ const result: T[] = [];
223
+ for (const item of items) {
224
+ const projectId = await projectIdOf(item);
225
+ if (!projectId) continue;
226
+ if (!readable.has(projectId)) readable.set(projectId, await this.canReadProject(projectId));
227
+ if (readable.get(projectId)) result.push(item);
228
+ }
229
+ return result;
230
+ }
231
+
232
+ private async projectIdOfTask(taskId: string | undefined): Promise<string | undefined> {
233
+ return taskId ? (await this.store.getTask(taskId))?.projectId : undefined;
234
+ }
235
+
236
+ private async projectIdOfComment(commentId: string | undefined): Promise<string | undefined> {
237
+ if (!commentId) return undefined;
238
+ const comment = await this.store.getComment(commentId);
239
+ return this.projectIdOfTask(comment?.taskId);
240
+ }
241
+
242
+ private async projectIdOfAttachment(a: { projectId?: string; taskId?: string; commentId?: string }): Promise<string | undefined> {
243
+ return a.projectId ?? (await this.projectIdOfTask(a.taskId)) ?? (await this.projectIdOfComment(a.commentId));
244
+ }
245
+
246
+ /** Strips fields only the engine may set (tenancy) from updates made through a view. */
247
+ private withoutTenant<T extends { tenantId?: string }>(updates: T): T {
248
+ if (!this.actor || !('tenantId' in updates)) return updates;
249
+ const { tenantId: _ignored, ...rest } = updates;
250
+ return rest as T;
251
+ }
252
+
143
253
  private async seedInitialData(data: NonNullable<CriticalPathConfig['initialData']>): Promise<void> {
144
254
  if (data.workflows) {
145
255
  for (const wf of data.workflows) {
@@ -180,15 +290,18 @@ export class CriticalPathEngine {
180
290
 
181
291
  // --- Workflows ---
182
292
  async getWorkflows(): Promise<Workflow[]> {
183
- return this.store.getWorkflows();
293
+ const workflows = await this.store.getWorkflows();
294
+ return workflows.filter((w) => this.inActorTenant(w));
184
295
  }
185
296
 
186
297
  async getWorkflow(id: string): Promise<Workflow | null> {
187
- return this.store.getWorkflow(id);
298
+ const workflow = await this.store.getWorkflow(id);
299
+ return workflow && this.inActorTenant(workflow) ? workflow : null;
188
300
  }
189
301
 
190
302
  async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
191
- const created = await this.store.createWorkflow(workflow);
303
+ await this.requireWorkspaceAccess('workspace.manage');
304
+ const created = await this.store.createWorkflow({ ...workflow, tenantId: this.actor ? this.actor.tenantId : workflow.tenantId });
192
305
  const now = new Date().toISOString();
193
306
 
194
307
  const event: WorkflowCreatedEvent = {
@@ -211,8 +324,10 @@ export class CriticalPathEngine {
211
324
  }
212
325
 
213
326
  async updateWorkflow(id: string, updates: Partial<Workflow>): Promise<Workflow | null> {
214
- const existing = await this.store.getWorkflow(id);
327
+ const existing = await this.getWorkflow(id);
215
328
  if (!existing) return null;
329
+ await this.requireWorkspaceAccess('workspace.manage');
330
+ updates = this.withoutTenant(updates);
216
331
 
217
332
  const updated = await this.store.updateWorkflow(id, updates);
218
333
  if (updated) {
@@ -238,8 +353,9 @@ export class CriticalPathEngine {
238
353
  }
239
354
 
240
355
  async deleteWorkflow(id: string): Promise<boolean> {
241
- const existing = await this.store.getWorkflow(id);
356
+ const existing = await this.getWorkflow(id);
242
357
  if (!existing) return false;
358
+ await this.requireWorkspaceAccess('workspace.manage');
243
359
 
244
360
  const deleted = await this.store.deleteWorkflow(id);
245
361
  if (deleted) {
@@ -272,7 +388,8 @@ export class CriticalPathEngine {
272
388
  if (wf) return wf;
273
389
  }
274
390
 
275
- const workflows = await this.store.getWorkflows();
391
+ // Only fall back to workflows from the project's own tenant.
392
+ const workflows = (await this.store.getWorkflows()).filter((w) => w.tenantId === project?.tenantId);
276
393
  const defaultWf = workflows.find((w) => w.isDefault);
277
394
  if (defaultWf) return defaultWf;
278
395
  if (workflows.length > 0) return workflows[0];
@@ -281,21 +398,21 @@ export class CriticalPathEngine {
281
398
  }
282
399
 
283
400
  async getAllowedTaskTransitions(taskId: string): Promise<string[]> {
284
- const task = await this.store.getTask(taskId);
401
+ const task = await this.getTask(taskId);
285
402
  if (!task) return [];
286
403
  const workflow = await this.resolveProjectWorkflow(task.projectId);
287
404
  return getAllowedTransitions(workflow || undefined, task.status);
288
405
  }
289
406
 
290
407
  async getAllowedNextTaskTransitions(taskId: string): Promise<string[]> {
291
- const task = await this.store.getTask(taskId);
408
+ const task = await this.getTask(taskId);
292
409
  if (!task) return [];
293
410
  const workflow = await this.resolveProjectWorkflow(task.projectId);
294
411
  return getAllowedNextStatuses(workflow || undefined, task.status);
295
412
  }
296
413
 
297
414
  async getAllowedPreviousTaskTransitions(taskId: string): Promise<string[]> {
298
- const task = await this.store.getTask(taskId);
415
+ const task = await this.getTask(taskId);
299
416
  if (!task) return [];
300
417
  const workflow = await this.resolveProjectWorkflow(task.projectId);
301
418
  return getAllowedPreviousStatuses(workflow || undefined, task.status);
@@ -303,15 +420,24 @@ export class CriticalPathEngine {
303
420
 
304
421
  // --- Projects ---
305
422
  async getProjects(): Promise<Project[]> {
306
- return this.store.getProjects();
423
+ const projects = await this.store.getProjects();
424
+ return this.filterReadable(projects, (p) => p.id);
307
425
  }
308
426
 
309
427
  async getProject(id: string): Promise<Project | null> {
310
- return this.store.getProject(id);
428
+ if (!this.enforcing) return this.store.getProject(id);
429
+ return this.readableProject(id);
311
430
  }
312
431
 
313
432
  async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
314
- const created = await this.store.createProject(project);
433
+ await this.requireWorkspaceAccess('project.create');
434
+ // Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
435
+ const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
436
+ // The creator administers the project they create.
437
+ if (this.actor && this.actor.userId !== 'anonymous' && !input.members?.some((m) => m.userId === this.actor!.userId)) {
438
+ input.members = [...(input.members ?? []), { userId: this.actor.userId, role: 'admin' }];
439
+ }
440
+ const created = await this.store.createProject(input);
315
441
  const now = new Date().toISOString();
316
442
 
317
443
  const event: ProjectCreatedEvent = {
@@ -335,8 +461,11 @@ export class CriticalPathEngine {
335
461
  }
336
462
 
337
463
  async updateProject(id: string, updates: Partial<Project>): Promise<Project | null> {
338
- const existing = await this.store.getProject(id);
464
+ const existing = await this.getProject(id);
339
465
  if (!existing) return null;
466
+ await this.requireProjectAccess('project.update', id);
467
+ if ('members' in updates) await this.requireProjectAccess('project.manage_members', id);
468
+ updates = this.withoutTenant(updates);
340
469
 
341
470
  const updated = await this.store.updateProject(id, updates);
342
471
  if (updated) {
@@ -367,13 +496,16 @@ export class CriticalPathEngine {
367
496
  * hooks run and `task.deleted` events fire before `project.deleted` is published.
368
497
  */
369
498
  async deleteProject(id: string): Promise<boolean> {
370
- const existing = await this.store.getProject(id);
499
+ const existing = await this.getProject(id);
371
500
  if (!existing) return false;
501
+ await this.requireProjectAccess('project.delete', id);
502
+ // Deleting the project implies deleting its tasks; skip per-task checks for the cascade.
503
+ const cascade = this.elevated();
372
504
 
373
505
  const tasks = await this.store.getTasks(id);
374
506
  const deletedTaskIds: string[] = [];
375
507
  for (const task of tasks) {
376
- if (await this.deleteTask(task.id)) {
508
+ if (await cascade.deleteTask(task.id)) {
377
509
  deletedTaskIds.push(task.id);
378
510
  }
379
511
  }
@@ -404,14 +536,19 @@ export class CriticalPathEngine {
404
536
 
405
537
  // --- Tasks ---
406
538
  async getTasks(projectId?: string): Promise<Task[]> {
407
- return this.store.getTasks(projectId);
539
+ if (projectId && !(await this.canReadProject(projectId))) return [];
540
+ const tasks = await this.store.getTasks(projectId);
541
+ return projectId ? tasks : this.filterReadable(tasks, (t) => t.projectId);
408
542
  }
409
543
 
410
544
  async getTask(id: string): Promise<Task | null> {
411
- return this.store.getTask(id);
545
+ const task = await this.store.getTask(id);
546
+ if (!task) return null;
547
+ return (await this.canReadProject(task.projectId)) ? task : null;
412
548
  }
413
549
 
414
550
  async createTask(taskInput: CreateTaskInput): Promise<Task> {
551
+ await this.requireProjectAccess('task.create', taskInput.projectId);
415
552
  if (this.actor && !taskInput.reporterId) {
416
553
  taskInput = { ...taskInput, reporterId: this.actor.userId };
417
554
  }
@@ -511,12 +648,7 @@ export class CriticalPathEngine {
511
648
 
512
649
  async updateTask(
513
650
  id: string,
514
- updates: Partial<Task> & {
515
- actorId?: string;
516
- actorName?: string;
517
- actorType?: string;
518
- actor?: { userId: string; username?: string; actorType?: string };
519
- },
651
+ updates: Partial<Task>,
520
652
  options?: {
521
653
  actorId?: string;
522
654
  actorName?: string;
@@ -524,45 +656,19 @@ export class CriticalPathEngine {
524
656
  actor?: { userId: string; username?: string; actorType?: string };
525
657
  }
526
658
  ): Promise<Task | null> {
527
- const existing = await this.store.getTask(id);
659
+ const existing = await this.getTask(id);
528
660
  if (!existing) return null;
661
+ await this.requireProjectAccess('task.update', existing.projectId);
529
662
 
530
- // On a withActor view, the scoped actor replaces any identity claimed in the payload.
531
- // Explicit options from trusted server-side code still take precedence.
663
+ // Identity comes from trusted options or the withActor view, never from the update payload.
532
664
  options = options ?? (this.actor ? { actor: this.actor } : undefined);
533
- const claimed = this.actor ? undefined : updates;
534
-
535
- const actorId =
536
- options?.actorId ||
537
- claimed?.actorId ||
538
- options?.actor?.userId ||
539
- claimed?.actor?.userId ||
540
- 'system';
665
+ const actorId = options?.actorId || options?.actor?.userId || 'system';
541
666
  const actorName =
542
- options?.actorName ||
543
- claimed?.actorName ||
544
- options?.actor?.username ||
545
- claimed?.actor?.username ||
546
- (actorId === 'system' ? 'System' : undefined);
667
+ options?.actorName || options?.actor?.username || (actorId === 'system' ? 'System' : undefined);
547
668
  const actorType =
548
- options?.actorType ||
549
- claimed?.actorType ||
550
- options?.actor?.actorType ||
551
- claimed?.actor?.actorType ||
552
- (actorId === 'system' ? 'system' : 'user');
553
- const actorObj = options?.actor || claimed?.actor || {
554
- userId: actorId,
555
- username: actorName,
556
- actorType
557
- };
558
-
559
- const {
560
- actorId: _aId,
561
- actorName: _aName,
562
- actorType: _aType,
563
- actor: _aObj,
564
- ...taskUpdates
565
- } = updates;
669
+ options?.actorType || options?.actor?.actorType || (actorId === 'system' ? 'system' : 'user');
670
+ const actorObj = options?.actor || { userId: actorId, username: actorName, actorType };
671
+ const taskUpdates = updates;
566
672
 
567
673
  const project = await this.store.getProject(existing.projectId);
568
674
  const workflow = await this.resolveProjectWorkflow(existing.projectId);
@@ -948,8 +1054,9 @@ export class CriticalPathEngine {
948
1054
  }
949
1055
 
950
1056
  async deleteTask(id: string): Promise<boolean> {
951
- const existing = await this.store.getTask(id);
1057
+ const existing = await this.getTask(id);
952
1058
  if (!existing) return false;
1059
+ await this.requireProjectAccess('task.delete', existing.projectId);
953
1060
 
954
1061
  await this.plugins.runBeforeTaskDelete(id);
955
1062
  const deleted = await this.store.deleteTask(id);
@@ -986,6 +1093,12 @@ export class CriticalPathEngine {
986
1093
 
987
1094
  // --- Task Dependencies & Graph ---
988
1095
  async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
1096
+ if (this.enforcing) {
1097
+ const [task, upstream] = await Promise.all([this.getTask(dep.taskId), this.getTask(dep.dependsOnTaskId)]);
1098
+ if (!task) throw new NotFoundError(`Task "${dep.taskId}" not found.`);
1099
+ if (!upstream) throw new NotFoundError(`Task "${dep.dependsOnTaskId}" not found.`);
1100
+ await this.requireProjectAccess('task.update', task.projectId);
1101
+ }
989
1102
  // Enforce Directed Acyclic Graph (DAG) Invariant
990
1103
  const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
991
1104
  const cycleCheck = detectDependencyCycle(reachableDeps, dep);
@@ -1041,6 +1154,9 @@ export class CriticalPathEngine {
1041
1154
  }
1042
1155
 
1043
1156
  async getTaskDependencyGraph(taskId: string): Promise<TaskDependencyGraph> {
1157
+ if (this.enforcing && !(await this.getTask(taskId))) {
1158
+ return { taskId, upstreamTasks: [], downstreamTasks: [], dependencies: [] };
1159
+ }
1044
1160
  const dependencies = await this.store.getDependencies(taskId);
1045
1161
  const upstreamTaskIds = new Set<string>();
1046
1162
  const downstreamTaskIds = new Set<string>();
@@ -1054,11 +1170,11 @@ export class CriticalPathEngine {
1054
1170
  }
1055
1171
 
1056
1172
  const upstreamTasks = (
1057
- await Promise.all(Array.from(upstreamTaskIds).map((id) => this.store.getTask(id)))
1173
+ await Promise.all(Array.from(upstreamTaskIds).map((id) => this.getTask(id)))
1058
1174
  ).filter((t): t is Task => t !== null);
1059
1175
 
1060
1176
  const downstreamTasks = (
1061
- await Promise.all(Array.from(downstreamTaskIds).map((id) => this.store.getTask(id)))
1177
+ await Promise.all(Array.from(downstreamTaskIds).map((id) => this.getTask(id)))
1062
1178
  ).filter((t): t is Task => t !== null);
1063
1179
 
1064
1180
  return {
@@ -1096,7 +1212,11 @@ export class CriticalPathEngine {
1096
1212
  throw new ValidationError('Logged hours must be a positive number.');
1097
1213
  }
1098
1214
 
1099
- const task = await this.store.getTask(entry.taskId);
1215
+ const task = await this.getTask(entry.taskId);
1216
+ if (this.enforcing) {
1217
+ if (!task) throw new NotFoundError(`Task "${entry.taskId}" not found.`);
1218
+ await this.requireProjectAccess('time.log', task.projectId);
1219
+ }
1100
1220
  if (task) {
1101
1221
  const newLoggedHours = (task.loggedHours || 0) + entry.hours;
1102
1222
  const newActualHours = (task.actualHours || 0) + entry.hours;
@@ -1132,17 +1252,37 @@ export class CriticalPathEngine {
1132
1252
 
1133
1253
  // --- Comments ---
1134
1254
  async getComments(taskId: string): Promise<Comment[]> {
1255
+ if (this.enforcing && !(await this.getTask(taskId))) return [];
1135
1256
  return this.store.getComments(taskId);
1136
1257
  }
1137
1258
 
1138
1259
  async getComment(id: string): Promise<Comment | null> {
1139
- return this.store.getComment(id);
1260
+ const comment = await this.store.getComment(id);
1261
+ if (!comment) return null;
1262
+ return (await this.canReadProject(await this.projectIdOfTask(comment.taskId))) ? comment : null;
1140
1263
  }
1141
1264
 
1142
- async addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment> {
1143
- if (this.actor) {
1144
- comment = { ...comment, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' };
1265
+ async getActivities(filter?: { projectId?: string; taskId?: string }): Promise<Activity[]> {
1266
+ const activities = await this.store.getActivities(filter);
1267
+ return this.filterReadable(activities, (a) => a.projectId ?? this.projectIdOfTask(a.taskId));
1268
+ }
1269
+
1270
+ async getTimeEntries(taskId: string): Promise<TimeEntry[]> {
1271
+ if (this.enforcing && !(await this.getTask(taskId))) return [];
1272
+ return this.store.getTimeEntries(taskId);
1273
+ }
1274
+
1275
+ async addComment(
1276
+ input: Omit<Comment, 'id' | 'createdAt' | 'updatedAt' | 'authorId'> & { authorId?: string }
1277
+ ): Promise<Comment> {
1278
+ if (this.enforcing) {
1279
+ const task = await this.getTask(input.taskId);
1280
+ if (!task) throw new NotFoundError(`Task "${input.taskId}" not found.`);
1281
+ await this.requireProjectAccess('comment.create', task.projectId);
1145
1282
  }
1283
+ const comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'> = this.actor
1284
+ ? { ...input, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' }
1285
+ : { ...input, authorId: input.authorId ?? 'system' };
1146
1286
  const mentions = comment.mentions ?? extractMentions(comment.content);
1147
1287
  const created = await this.store.addComment({ ...comment, mentions });
1148
1288
  const now = new Date().toISOString();
@@ -1162,8 +1302,12 @@ export class CriticalPathEngine {
1162
1302
  }
1163
1303
 
1164
1304
  async updateComment(id: string, updates: Partial<Comment>): Promise<Comment | null> {
1165
- const existing = await this.store.getComment(id);
1305
+ const existing = await this.getComment(id);
1166
1306
  if (!existing) return null;
1307
+ await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
1308
+ type: 'comment',
1309
+ ownerId: existing.authorId
1310
+ });
1167
1311
 
1168
1312
  const mentions = updates.mentions ?? (updates.content ? extractMentions(updates.content) : undefined);
1169
1313
  const toUpdate = mentions !== undefined ? { ...updates, mentions } : updates;
@@ -1187,8 +1331,12 @@ export class CriticalPathEngine {
1187
1331
  }
1188
1332
 
1189
1333
  async deleteComment(id: string): Promise<boolean> {
1190
- const existing = await this.store.getComment(id);
1334
+ const existing = await this.getComment(id);
1191
1335
  if (!existing) return false;
1336
+ await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
1337
+ type: 'comment',
1338
+ ownerId: existing.authorId
1339
+ });
1192
1340
 
1193
1341
  const deleted = await this.store.deleteComment(id);
1194
1342
  if (deleted) {
@@ -1208,10 +1356,11 @@ export class CriticalPathEngine {
1208
1356
  return deleted;
1209
1357
  }
1210
1358
 
1211
- async addCommentReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
1212
- if (this.actor) reaction = { ...reaction, userId: this.actor.userId };
1213
- const existing = await this.store.getComment(commentId);
1359
+ async addCommentReaction(commentId: string, input: { emoji: string; userId?: string }): Promise<Comment | null> {
1360
+ const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
1361
+ const existing = await this.getComment(commentId);
1214
1362
  if (!existing) return null;
1363
+ await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
1215
1364
 
1216
1365
  let updated: Comment | null;
1217
1366
  if (this.store.addReaction) {
@@ -1254,10 +1403,11 @@ export class CriticalPathEngine {
1254
1403
  return updated;
1255
1404
  }
1256
1405
 
1257
- async removeCommentReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
1258
- if (this.actor) reaction = { ...reaction, userId: this.actor.userId };
1259
- const existing = await this.store.getComment(commentId);
1406
+ async removeCommentReaction(commentId: string, input: { emoji: string; userId?: string }): Promise<Comment | null> {
1407
+ const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
1408
+ const existing = await this.getComment(commentId);
1260
1409
  if (!existing) return null;
1410
+ await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
1261
1411
 
1262
1412
  let updated: Comment | null;
1263
1413
  if (this.store.removeReaction) {
@@ -1288,7 +1438,9 @@ export class CriticalPathEngine {
1288
1438
 
1289
1439
  // --- Attachments ---
1290
1440
  async getAttachments(filter?: { taskId?: string; projectId?: string; commentId?: string; artifactType?: string }): Promise<Attachment[]> {
1291
- const attachments = await this.store.getAttachments(filter);
1441
+ const attachments = await this.filterReadable(await this.store.getAttachments(filter), (a) =>
1442
+ this.projectIdOfAttachment(a)
1443
+ );
1292
1444
  if (filter?.artifactType) {
1293
1445
  return attachments.filter(a => a.artifactType === filter.artifactType);
1294
1446
  }
@@ -1296,13 +1448,25 @@ export class CriticalPathEngine {
1296
1448
  }
1297
1449
 
1298
1450
  async getAttachment(id: string): Promise<Attachment | null> {
1299
- return this.store.getAttachment(id);
1451
+ const attachment = await this.store.getAttachment(id);
1452
+ if (!attachment) return null;
1453
+ return (await this.canReadProject(await this.projectIdOfAttachment(attachment))) ? attachment : null;
1300
1454
  }
1301
1455
 
1302
- async createAttachment(attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Attachment> {
1303
- if (this.actor) {
1304
- attachment = { ...attachment, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' };
1305
- }
1456
+ private async requireAttachmentCreate(link: { projectId?: string; taskId?: string; commentId?: string }): Promise<void> {
1457
+ if (!this.enforcing) return;
1458
+ const projectId = await this.projectIdOfAttachment(link);
1459
+ if (!projectId) throw new ValidationError('Attachments must reference a project, task or comment.');
1460
+ await this.requireProjectAccess('attachment.create', projectId);
1461
+ }
1462
+
1463
+ async createAttachment(
1464
+ input: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt' | 'uploaderId'> & { uploaderId?: string }
1465
+ ): Promise<Attachment> {
1466
+ await this.requireAttachmentCreate(input);
1467
+ const attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'> = this.actor
1468
+ ? { ...input, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' }
1469
+ : { ...input, uploaderId: input.uploaderId ?? 'system' };
1306
1470
  validateAttachmentUrl(attachment.url);
1307
1471
  const created = await this.store.createAttachment(attachment);
1308
1472
  const now = new Date().toISOString();
@@ -1322,8 +1486,12 @@ export class CriticalPathEngine {
1322
1486
  }
1323
1487
 
1324
1488
  async deleteAttachment(id: string): Promise<boolean> {
1325
- const existing = await this.store.getAttachment(id);
1489
+ const existing = await this.getAttachment(id);
1326
1490
  if (!existing) return false;
1491
+ await this.requireProjectAccess('attachment.delete', await this.projectIdOfAttachment(existing), {
1492
+ type: 'attachment',
1493
+ ownerId: existing.uploaderId
1494
+ });
1327
1495
 
1328
1496
  if (existing.storageKey && this.fileStorage) {
1329
1497
  try {
@@ -1356,7 +1524,7 @@ export class CriticalPathEngine {
1356
1524
  taskId?: string;
1357
1525
  projectId?: string;
1358
1526
  commentId?: string;
1359
- uploaderId: string;
1527
+ uploaderId?: string;
1360
1528
  uploaderType?: 'user' | 'agent' | 'system';
1361
1529
  artifactType?: 'plan' | 'spec' | 'deliverable' | 'review' | 'general';
1362
1530
  metadata?: Record<string, unknown>;
@@ -1365,6 +1533,8 @@ export class CriticalPathEngine {
1365
1533
  if (!this.fileStorage) {
1366
1534
  throw new Error('No FileStorageAdapter configured in CriticalPathEngine. Pass "fileStorage" in config to enable direct file uploads.');
1367
1535
  }
1536
+ // Check before uploading so denied requests do not leave orphaned files behind.
1537
+ await this.requireAttachmentCreate(input);
1368
1538
 
1369
1539
  const uploadResult = await this.fileStorage.upload({
1370
1540
  filename: input.filename,
@@ -1422,24 +1592,41 @@ export class CriticalPathEngine {
1422
1592
  throw new Error(`Cannot read attachment "${id}": no valid storageKey or URL found.`);
1423
1593
  }
1424
1594
 
1425
- async getPresignedAttachmentUploadUrl(options: PresignedUrlOptions): Promise<PresignedUploadResult> {
1595
+ async getPresignedAttachmentUploadUrl(
1596
+ options: PresignedUrlOptions & { projectId?: string }
1597
+ ): Promise<PresignedUploadResult> {
1426
1598
  if (!this.fileStorage || !this.fileStorage.getPresignedUploadUrl) {
1427
1599
  throw new Error('Presigned uploads are not supported by the configured FileStorageAdapter.');
1428
1600
  }
1429
- return this.fileStorage.getPresignedUploadUrl(options);
1601
+ const { projectId, ...presign } = options;
1602
+ if (this.enforcing) {
1603
+ // Scoped callers may only write under their project's prefix.
1604
+ if (!projectId) throw new ValidationError('projectId is required for presigned uploads.');
1605
+ await this.requireProjectAccess('attachment.create', projectId);
1606
+ const prefix = `projects/${projectId}/`;
1607
+ const key = presign.storageKey.replace(/^\/+/, '');
1608
+ if (key.split('/').some((segment) => segment === '..' || segment === '.')) {
1609
+ throw new ValidationError('storageKey must not contain "." or ".." segments.');
1610
+ }
1611
+ presign.storageKey = key.startsWith(prefix) ? key : prefix + key;
1612
+ }
1613
+ return this.fileStorage.getPresignedUploadUrl(presign);
1430
1614
  }
1431
1615
 
1432
1616
  // --- Teams ---
1433
1617
  async getTeams(): Promise<Team[]> {
1434
- return this.store.getTeams();
1618
+ const teams = await this.store.getTeams();
1619
+ return teams.filter((t) => this.inActorTenant(t));
1435
1620
  }
1436
1621
 
1437
1622
  async getTeam(id: string): Promise<Team | null> {
1438
- return this.store.getTeam(id);
1623
+ const team = await this.store.getTeam(id);
1624
+ return team && this.inActorTenant(team) ? team : null;
1439
1625
  }
1440
1626
 
1441
1627
  async createTeam(team: Omit<Team, 'id' | 'createdAt' | 'updatedAt'>): Promise<Team> {
1442
- const created = await this.store.createTeam(team);
1628
+ await this.requireWorkspaceAccess('workspace.manage');
1629
+ const created = await this.store.createTeam({ ...team, tenantId: this.actor ? this.actor.tenantId : team.tenantId });
1443
1630
  const now = new Date().toISOString();
1444
1631
 
1445
1632
  const event: TeamCreatedEvent = {
@@ -1457,23 +1644,31 @@ export class CriticalPathEngine {
1457
1644
  }
1458
1645
 
1459
1646
  async updateTeam(id: string, updates: Partial<Team>): Promise<Team | null> {
1460
- return this.store.updateTeam(id, updates);
1647
+ if (!(await this.getTeam(id))) return null;
1648
+ await this.requireWorkspaceAccess('workspace.manage');
1649
+ return this.store.updateTeam(id, this.withoutTenant(updates));
1461
1650
  }
1462
1651
 
1463
1652
  async deleteTeam(id: string): Promise<boolean> {
1653
+ if (!(await this.getTeam(id))) return false;
1654
+ await this.requireWorkspaceAccess('workspace.manage');
1464
1655
  return this.store.deleteTeam(id);
1465
1656
  }
1466
1657
 
1467
1658
  // --- Containers ---
1468
1659
  async getContainers(projectId: string): Promise<TaskContainer[]> {
1660
+ if (!(await this.canReadProject(projectId))) return [];
1469
1661
  return this.store.getContainers(projectId);
1470
1662
  }
1471
1663
 
1472
1664
  async getContainer(id: string): Promise<TaskContainer | null> {
1473
- return this.store.getContainer(id);
1665
+ const container = await this.store.getContainer(id);
1666
+ if (!container) return null;
1667
+ return (await this.canReadProject(container.projectId)) ? container : null;
1474
1668
  }
1475
1669
 
1476
1670
  async createContainer(container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'>): Promise<TaskContainer> {
1671
+ await this.requireProjectAccess('plan.manage', container.projectId);
1477
1672
  const created = await this.store.createContainer(container);
1478
1673
  const now = new Date().toISOString();
1479
1674
 
@@ -1492,23 +1687,33 @@ export class CriticalPathEngine {
1492
1687
  }
1493
1688
 
1494
1689
  async updateContainer(id: string, updates: Partial<TaskContainer>): Promise<TaskContainer | null> {
1690
+ const existing = await this.getContainer(id);
1691
+ if (!existing) return null;
1692
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1495
1693
  return this.store.updateContainer(id, updates);
1496
1694
  }
1497
1695
 
1498
1696
  async deleteContainer(id: string): Promise<boolean> {
1697
+ const existing = await this.getContainer(id);
1698
+ if (!existing) return false;
1699
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1499
1700
  return this.store.deleteContainer(id);
1500
1701
  }
1501
1702
 
1502
1703
  // --- Deliverables ---
1503
1704
  async getDeliverables(projectId: string): Promise<Deliverable[]> {
1705
+ if (!(await this.canReadProject(projectId))) return [];
1504
1706
  return this.store.getDeliverables(projectId);
1505
1707
  }
1506
1708
 
1507
1709
  async getDeliverable(id: string): Promise<Deliverable | null> {
1508
- return this.store.getDeliverable(id);
1710
+ const deliverable = await this.store.getDeliverable(id);
1711
+ if (!deliverable) return null;
1712
+ return (await this.canReadProject(deliverable.projectId)) ? deliverable : null;
1509
1713
  }
1510
1714
 
1511
1715
  async createDeliverable(input: CreateDeliverableInput): Promise<Deliverable> {
1716
+ await this.requireProjectAccess('plan.manage', input.projectId);
1512
1717
  const project = await this.store.getProject(input.projectId);
1513
1718
  if (project?.customFieldDefinitions && input.customFields) {
1514
1719
  validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
@@ -1543,8 +1748,9 @@ export class CriticalPathEngine {
1543
1748
  }
1544
1749
 
1545
1750
  async updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null> {
1546
- const existing = await this.store.getDeliverable(id);
1751
+ const existing = await this.getDeliverable(id);
1547
1752
  if (!existing) return null;
1753
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1548
1754
 
1549
1755
  if (updates.status && updates.status === 'delivered' && !existing.deliveredAt && !updates.deliveredAt) {
1550
1756
  updates.deliveredAt = new Date().toISOString();
@@ -1597,8 +1803,9 @@ export class CriticalPathEngine {
1597
1803
  }
1598
1804
 
1599
1805
  async deleteDeliverable(id: string): Promise<boolean> {
1600
- const existing = await this.store.getDeliverable(id);
1806
+ const existing = await this.getDeliverable(id);
1601
1807
  if (!existing) return false;
1808
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1602
1809
 
1603
1810
  const deleted = await this.store.deleteDeliverable(id);
1604
1811
  if (deleted) {
@@ -1625,7 +1832,7 @@ export class CriticalPathEngine {
1625
1832
  }
1626
1833
 
1627
1834
  async getDeliverableSummary(deliverableId: string): Promise<DeliverableSummary | null> {
1628
- const deliverable = await this.store.getDeliverable(deliverableId);
1835
+ const deliverable = await this.getDeliverable(deliverableId);
1629
1836
  if (!deliverable) return null;
1630
1837
 
1631
1838
  const project = await this.store.getProject(deliverable.projectId);
@@ -1673,14 +1880,18 @@ export class CriticalPathEngine {
1673
1880
 
1674
1881
  // --- Iterations ---
1675
1882
  async getIterations(projectId: string): Promise<Iteration[]> {
1883
+ if (!(await this.canReadProject(projectId))) return [];
1676
1884
  return this.store.getIterations(projectId);
1677
1885
  }
1678
1886
 
1679
1887
  async getIteration(id: string): Promise<Iteration | null> {
1680
- return this.store.getIteration(id);
1888
+ const iteration = await this.store.getIteration(id);
1889
+ if (!iteration) return null;
1890
+ return (await this.canReadProject(iteration.projectId)) ? iteration : null;
1681
1891
  }
1682
1892
 
1683
1893
  async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
1894
+ await this.requireProjectAccess('plan.manage', iteration.projectId);
1684
1895
  const created = await this.store.createIteration(iteration);
1685
1896
  if (created.status === 'active') {
1686
1897
  const now = new Date().toISOString();
@@ -1699,8 +1910,9 @@ export class CriticalPathEngine {
1699
1910
  }
1700
1911
 
1701
1912
  async updateIteration(id: string, updates: Partial<Iteration>): Promise<Iteration | null> {
1702
- const existing = await this.store.getIteration(id);
1913
+ const existing = await this.getIteration(id);
1703
1914
  if (!existing) return null;
1915
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1704
1916
 
1705
1917
  const updated = await this.store.updateIteration(id, updates);
1706
1918
  if (updated) {
@@ -1733,6 +1945,9 @@ export class CriticalPathEngine {
1733
1945
  }
1734
1946
 
1735
1947
  async deleteIteration(id: string): Promise<boolean> {
1948
+ const existing = await this.getIteration(id);
1949
+ if (!existing) return false;
1950
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1736
1951
  return this.store.deleteIteration(id);
1737
1952
  }
1738
1953
 
@@ -1741,6 +1956,7 @@ export class CriticalPathEngine {
1741
1956
  projectId: string,
1742
1957
  options: CPMOptions = {}
1743
1958
  ): Promise<CriticalPathAnalysis> {
1959
+ await this.requireProjectAccess('project.read', projectId);
1744
1960
  const project = await this.store.getProject(projectId);
1745
1961
  const tasks = await this.store.getTasks(projectId);
1746
1962
  const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
@@ -1763,6 +1979,7 @@ export class CriticalPathEngine {
1763
1979
  projectId: string,
1764
1980
  options: TimelineLadderOptions = {}
1765
1981
  ): Promise<TimelineLadder> {
1982
+ await this.requireProjectAccess('project.read', projectId);
1766
1983
  const project = await this.store.getProject(projectId);
1767
1984
  if (!project) {
1768
1985
  throw new NotFoundError(`Project with ID "${projectId}" not found.`);
@@ -1819,7 +2036,7 @@ export class CriticalPathEngine {
1819
2036
  }
1820
2037
 
1821
2038
  async getTaskLadder(taskId: string): Promise<TaskLadderView | null> {
1822
- const task = await this.store.getTask(taskId);
2039
+ const task = await this.getTask(taskId);
1823
2040
  if (!task) return null;
1824
2041
 
1825
2042
  const project = await this.store.getProject(task.projectId);
@@ -1843,7 +2060,7 @@ export class CriticalPathEngine {
1843
2060
  }
1844
2061
 
1845
2062
  async getTaskMetrics(taskId: string, options?: MetricOptions): Promise<TaskMetrics | null> {
1846
- const task = await this.store.getTask(taskId);
2063
+ const task = await this.getTask(taskId);
1847
2064
  if (!task) return null;
1848
2065
 
1849
2066
  const [activities, timeEntries, project, workflow] = await Promise.all([
@@ -1862,7 +2079,7 @@ export class CriticalPathEngine {
1862
2079
  }
1863
2080
 
1864
2081
  async getTaskProgressHistory(taskId: string, options?: MetricOptions): Promise<TaskProgressHistory | null> {
1865
- const task = await this.store.getTask(taskId);
2082
+ const task = await this.getTask(taskId);
1866
2083
  if (!task) return null;
1867
2084
 
1868
2085
  const [activities, project, workflow] = await Promise.all([
@@ -1883,8 +2100,9 @@ export class CriticalPathEngine {
1883
2100
  projectId?: string,
1884
2101
  options: WorkloadDistributionOptions = {}
1885
2102
  ): Promise<WorkloadDistribution> {
1886
- const tasks = await this.store.getTasks(projectId);
1887
- const teams = await this.store.getTeams();
2103
+ if (projectId) await this.requireProjectAccess('project.read', projectId);
2104
+ const tasks = await this.getTasks(projectId);
2105
+ const teams = await this.getTeams();
1888
2106
  const project = projectId ? await this.store.getProject(projectId) : undefined;
1889
2107
  const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
1890
2108