@critical-path/core 0.25.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 (47) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +12 -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/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/authorization.test.d.ts +2 -0
  19. package/dist/engine/authorization.test.d.ts.map +1 -0
  20. package/dist/engine/authorization.test.js +150 -0
  21. package/dist/engine/authorization.test.js.map +1 -0
  22. package/dist/engine/index.d.ts +37 -2
  23. package/dist/engine/index.d.ts.map +1 -1
  24. package/dist/engine/index.js +302 -48
  25. package/dist/engine/index.js.map +1 -1
  26. package/dist/schemas/index.d.ts +40 -6
  27. package/dist/schemas/index.d.ts.map +1 -1
  28. package/dist/schemas/index.js +5 -1
  29. package/dist/schemas/index.js.map +1 -1
  30. package/dist/schemas/schemas.test.js.map +1 -1
  31. package/dist/store/sqlite.d.ts.map +1 -1
  32. package/dist/store/sqlite.js +20 -9
  33. package/dist/store/sqlite.js.map +1 -1
  34. package/dist/types/index.d.ts +45 -1
  35. package/dist/types/index.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/domain/authorization.ts +82 -0
  38. package/src/domain/entities.ts +2 -1
  39. package/src/domain/errors.ts +10 -0
  40. package/src/domain/index.ts +1 -0
  41. package/src/engine/authorization.test.ts +191 -0
  42. package/src/engine/index.ts +295 -49
  43. package/src/schemas/index.ts +5 -1
  44. package/src/schemas/schemas.test.ts +3 -2
  45. package/src/store/sqlite.ts +20 -6
  46. package/src/types/index.ts +66 -1
  47. 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
  }
@@ -519,8 +656,9 @@ export class CriticalPathEngine {
519
656
  actor?: { userId: string; username?: string; actorType?: string };
520
657
  }
521
658
  ): Promise<Task | null> {
522
- const existing = await this.store.getTask(id);
659
+ const existing = await this.getTask(id);
523
660
  if (!existing) return null;
661
+ await this.requireProjectAccess('task.update', existing.projectId);
524
662
 
525
663
  // Identity comes from trusted options or the withActor view, never from the update payload.
526
664
  options = options ?? (this.actor ? { actor: this.actor } : undefined);
@@ -916,8 +1054,9 @@ export class CriticalPathEngine {
916
1054
  }
917
1055
 
918
1056
  async deleteTask(id: string): Promise<boolean> {
919
- const existing = await this.store.getTask(id);
1057
+ const existing = await this.getTask(id);
920
1058
  if (!existing) return false;
1059
+ await this.requireProjectAccess('task.delete', existing.projectId);
921
1060
 
922
1061
  await this.plugins.runBeforeTaskDelete(id);
923
1062
  const deleted = await this.store.deleteTask(id);
@@ -954,6 +1093,12 @@ export class CriticalPathEngine {
954
1093
 
955
1094
  // --- Task Dependencies & Graph ---
956
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
+ }
957
1102
  // Enforce Directed Acyclic Graph (DAG) Invariant
958
1103
  const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
959
1104
  const cycleCheck = detectDependencyCycle(reachableDeps, dep);
@@ -1009,6 +1154,9 @@ export class CriticalPathEngine {
1009
1154
  }
1010
1155
 
1011
1156
  async getTaskDependencyGraph(taskId: string): Promise<TaskDependencyGraph> {
1157
+ if (this.enforcing && !(await this.getTask(taskId))) {
1158
+ return { taskId, upstreamTasks: [], downstreamTasks: [], dependencies: [] };
1159
+ }
1012
1160
  const dependencies = await this.store.getDependencies(taskId);
1013
1161
  const upstreamTaskIds = new Set<string>();
1014
1162
  const downstreamTaskIds = new Set<string>();
@@ -1022,11 +1170,11 @@ export class CriticalPathEngine {
1022
1170
  }
1023
1171
 
1024
1172
  const upstreamTasks = (
1025
- await Promise.all(Array.from(upstreamTaskIds).map((id) => this.store.getTask(id)))
1173
+ await Promise.all(Array.from(upstreamTaskIds).map((id) => this.getTask(id)))
1026
1174
  ).filter((t): t is Task => t !== null);
1027
1175
 
1028
1176
  const downstreamTasks = (
1029
- await Promise.all(Array.from(downstreamTaskIds).map((id) => this.store.getTask(id)))
1177
+ await Promise.all(Array.from(downstreamTaskIds).map((id) => this.getTask(id)))
1030
1178
  ).filter((t): t is Task => t !== null);
1031
1179
 
1032
1180
  return {
@@ -1064,7 +1212,11 @@ export class CriticalPathEngine {
1064
1212
  throw new ValidationError('Logged hours must be a positive number.');
1065
1213
  }
1066
1214
 
1067
- 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
+ }
1068
1220
  if (task) {
1069
1221
  const newLoggedHours = (task.loggedHours || 0) + entry.hours;
1070
1222
  const newActualHours = (task.actualHours || 0) + entry.hours;
@@ -1100,16 +1252,34 @@ export class CriticalPathEngine {
1100
1252
 
1101
1253
  // --- Comments ---
1102
1254
  async getComments(taskId: string): Promise<Comment[]> {
1255
+ if (this.enforcing && !(await this.getTask(taskId))) return [];
1103
1256
  return this.store.getComments(taskId);
1104
1257
  }
1105
1258
 
1106
1259
  async getComment(id: string): Promise<Comment | null> {
1107
- 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;
1263
+ }
1264
+
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);
1108
1273
  }
1109
1274
 
1110
1275
  async addComment(
1111
1276
  input: Omit<Comment, 'id' | 'createdAt' | 'updatedAt' | 'authorId'> & { authorId?: string }
1112
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);
1282
+ }
1113
1283
  const comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'> = this.actor
1114
1284
  ? { ...input, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' }
1115
1285
  : { ...input, authorId: input.authorId ?? 'system' };
@@ -1132,8 +1302,12 @@ export class CriticalPathEngine {
1132
1302
  }
1133
1303
 
1134
1304
  async updateComment(id: string, updates: Partial<Comment>): Promise<Comment | null> {
1135
- const existing = await this.store.getComment(id);
1305
+ const existing = await this.getComment(id);
1136
1306
  if (!existing) return null;
1307
+ await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
1308
+ type: 'comment',
1309
+ ownerId: existing.authorId
1310
+ });
1137
1311
 
1138
1312
  const mentions = updates.mentions ?? (updates.content ? extractMentions(updates.content) : undefined);
1139
1313
  const toUpdate = mentions !== undefined ? { ...updates, mentions } : updates;
@@ -1157,8 +1331,12 @@ export class CriticalPathEngine {
1157
1331
  }
1158
1332
 
1159
1333
  async deleteComment(id: string): Promise<boolean> {
1160
- const existing = await this.store.getComment(id);
1334
+ const existing = await this.getComment(id);
1161
1335
  if (!existing) return false;
1336
+ await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
1337
+ type: 'comment',
1338
+ ownerId: existing.authorId
1339
+ });
1162
1340
 
1163
1341
  const deleted = await this.store.deleteComment(id);
1164
1342
  if (deleted) {
@@ -1180,8 +1358,9 @@ export class CriticalPathEngine {
1180
1358
 
1181
1359
  async addCommentReaction(commentId: string, input: { emoji: string; userId?: string }): Promise<Comment | null> {
1182
1360
  const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
1183
- const existing = await this.store.getComment(commentId);
1361
+ const existing = await this.getComment(commentId);
1184
1362
  if (!existing) return null;
1363
+ await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
1185
1364
 
1186
1365
  let updated: Comment | null;
1187
1366
  if (this.store.addReaction) {
@@ -1226,8 +1405,9 @@ export class CriticalPathEngine {
1226
1405
 
1227
1406
  async removeCommentReaction(commentId: string, input: { emoji: string; userId?: string }): Promise<Comment | null> {
1228
1407
  const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
1229
- const existing = await this.store.getComment(commentId);
1408
+ const existing = await this.getComment(commentId);
1230
1409
  if (!existing) return null;
1410
+ await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
1231
1411
 
1232
1412
  let updated: Comment | null;
1233
1413
  if (this.store.removeReaction) {
@@ -1258,7 +1438,9 @@ export class CriticalPathEngine {
1258
1438
 
1259
1439
  // --- Attachments ---
1260
1440
  async getAttachments(filter?: { taskId?: string; projectId?: string; commentId?: string; artifactType?: string }): Promise<Attachment[]> {
1261
- const attachments = await this.store.getAttachments(filter);
1441
+ const attachments = await this.filterReadable(await this.store.getAttachments(filter), (a) =>
1442
+ this.projectIdOfAttachment(a)
1443
+ );
1262
1444
  if (filter?.artifactType) {
1263
1445
  return attachments.filter(a => a.artifactType === filter.artifactType);
1264
1446
  }
@@ -1266,12 +1448,22 @@ export class CriticalPathEngine {
1266
1448
  }
1267
1449
 
1268
1450
  async getAttachment(id: string): Promise<Attachment | null> {
1269
- 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;
1454
+ }
1455
+
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);
1270
1461
  }
1271
1462
 
1272
1463
  async createAttachment(
1273
1464
  input: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt' | 'uploaderId'> & { uploaderId?: string }
1274
1465
  ): Promise<Attachment> {
1466
+ await this.requireAttachmentCreate(input);
1275
1467
  const attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'> = this.actor
1276
1468
  ? { ...input, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' }
1277
1469
  : { ...input, uploaderId: input.uploaderId ?? 'system' };
@@ -1294,8 +1486,12 @@ export class CriticalPathEngine {
1294
1486
  }
1295
1487
 
1296
1488
  async deleteAttachment(id: string): Promise<boolean> {
1297
- const existing = await this.store.getAttachment(id);
1489
+ const existing = await this.getAttachment(id);
1298
1490
  if (!existing) return false;
1491
+ await this.requireProjectAccess('attachment.delete', await this.projectIdOfAttachment(existing), {
1492
+ type: 'attachment',
1493
+ ownerId: existing.uploaderId
1494
+ });
1299
1495
 
1300
1496
  if (existing.storageKey && this.fileStorage) {
1301
1497
  try {
@@ -1337,6 +1533,8 @@ export class CriticalPathEngine {
1337
1533
  if (!this.fileStorage) {
1338
1534
  throw new Error('No FileStorageAdapter configured in CriticalPathEngine. Pass "fileStorage" in config to enable direct file uploads.');
1339
1535
  }
1536
+ // Check before uploading so denied requests do not leave orphaned files behind.
1537
+ await this.requireAttachmentCreate(input);
1340
1538
 
1341
1539
  const uploadResult = await this.fileStorage.upload({
1342
1540
  filename: input.filename,
@@ -1394,24 +1592,41 @@ export class CriticalPathEngine {
1394
1592
  throw new Error(`Cannot read attachment "${id}": no valid storageKey or URL found.`);
1395
1593
  }
1396
1594
 
1397
- async getPresignedAttachmentUploadUrl(options: PresignedUrlOptions): Promise<PresignedUploadResult> {
1595
+ async getPresignedAttachmentUploadUrl(
1596
+ options: PresignedUrlOptions & { projectId?: string }
1597
+ ): Promise<PresignedUploadResult> {
1398
1598
  if (!this.fileStorage || !this.fileStorage.getPresignedUploadUrl) {
1399
1599
  throw new Error('Presigned uploads are not supported by the configured FileStorageAdapter.');
1400
1600
  }
1401
- 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);
1402
1614
  }
1403
1615
 
1404
1616
  // --- Teams ---
1405
1617
  async getTeams(): Promise<Team[]> {
1406
- return this.store.getTeams();
1618
+ const teams = await this.store.getTeams();
1619
+ return teams.filter((t) => this.inActorTenant(t));
1407
1620
  }
1408
1621
 
1409
1622
  async getTeam(id: string): Promise<Team | null> {
1410
- return this.store.getTeam(id);
1623
+ const team = await this.store.getTeam(id);
1624
+ return team && this.inActorTenant(team) ? team : null;
1411
1625
  }
1412
1626
 
1413
1627
  async createTeam(team: Omit<Team, 'id' | 'createdAt' | 'updatedAt'>): Promise<Team> {
1414
- 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 });
1415
1630
  const now = new Date().toISOString();
1416
1631
 
1417
1632
  const event: TeamCreatedEvent = {
@@ -1429,23 +1644,31 @@ export class CriticalPathEngine {
1429
1644
  }
1430
1645
 
1431
1646
  async updateTeam(id: string, updates: Partial<Team>): Promise<Team | null> {
1432
- 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));
1433
1650
  }
1434
1651
 
1435
1652
  async deleteTeam(id: string): Promise<boolean> {
1653
+ if (!(await this.getTeam(id))) return false;
1654
+ await this.requireWorkspaceAccess('workspace.manage');
1436
1655
  return this.store.deleteTeam(id);
1437
1656
  }
1438
1657
 
1439
1658
  // --- Containers ---
1440
1659
  async getContainers(projectId: string): Promise<TaskContainer[]> {
1660
+ if (!(await this.canReadProject(projectId))) return [];
1441
1661
  return this.store.getContainers(projectId);
1442
1662
  }
1443
1663
 
1444
1664
  async getContainer(id: string): Promise<TaskContainer | null> {
1445
- 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;
1446
1668
  }
1447
1669
 
1448
1670
  async createContainer(container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'>): Promise<TaskContainer> {
1671
+ await this.requireProjectAccess('plan.manage', container.projectId);
1449
1672
  const created = await this.store.createContainer(container);
1450
1673
  const now = new Date().toISOString();
1451
1674
 
@@ -1464,23 +1687,33 @@ export class CriticalPathEngine {
1464
1687
  }
1465
1688
 
1466
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);
1467
1693
  return this.store.updateContainer(id, updates);
1468
1694
  }
1469
1695
 
1470
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);
1471
1700
  return this.store.deleteContainer(id);
1472
1701
  }
1473
1702
 
1474
1703
  // --- Deliverables ---
1475
1704
  async getDeliverables(projectId: string): Promise<Deliverable[]> {
1705
+ if (!(await this.canReadProject(projectId))) return [];
1476
1706
  return this.store.getDeliverables(projectId);
1477
1707
  }
1478
1708
 
1479
1709
  async getDeliverable(id: string): Promise<Deliverable | null> {
1480
- 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;
1481
1713
  }
1482
1714
 
1483
1715
  async createDeliverable(input: CreateDeliverableInput): Promise<Deliverable> {
1716
+ await this.requireProjectAccess('plan.manage', input.projectId);
1484
1717
  const project = await this.store.getProject(input.projectId);
1485
1718
  if (project?.customFieldDefinitions && input.customFields) {
1486
1719
  validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
@@ -1515,8 +1748,9 @@ export class CriticalPathEngine {
1515
1748
  }
1516
1749
 
1517
1750
  async updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null> {
1518
- const existing = await this.store.getDeliverable(id);
1751
+ const existing = await this.getDeliverable(id);
1519
1752
  if (!existing) return null;
1753
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1520
1754
 
1521
1755
  if (updates.status && updates.status === 'delivered' && !existing.deliveredAt && !updates.deliveredAt) {
1522
1756
  updates.deliveredAt = new Date().toISOString();
@@ -1569,8 +1803,9 @@ export class CriticalPathEngine {
1569
1803
  }
1570
1804
 
1571
1805
  async deleteDeliverable(id: string): Promise<boolean> {
1572
- const existing = await this.store.getDeliverable(id);
1806
+ const existing = await this.getDeliverable(id);
1573
1807
  if (!existing) return false;
1808
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1574
1809
 
1575
1810
  const deleted = await this.store.deleteDeliverable(id);
1576
1811
  if (deleted) {
@@ -1597,7 +1832,7 @@ export class CriticalPathEngine {
1597
1832
  }
1598
1833
 
1599
1834
  async getDeliverableSummary(deliverableId: string): Promise<DeliverableSummary | null> {
1600
- const deliverable = await this.store.getDeliverable(deliverableId);
1835
+ const deliverable = await this.getDeliverable(deliverableId);
1601
1836
  if (!deliverable) return null;
1602
1837
 
1603
1838
  const project = await this.store.getProject(deliverable.projectId);
@@ -1645,14 +1880,18 @@ export class CriticalPathEngine {
1645
1880
 
1646
1881
  // --- Iterations ---
1647
1882
  async getIterations(projectId: string): Promise<Iteration[]> {
1883
+ if (!(await this.canReadProject(projectId))) return [];
1648
1884
  return this.store.getIterations(projectId);
1649
1885
  }
1650
1886
 
1651
1887
  async getIteration(id: string): Promise<Iteration | null> {
1652
- 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;
1653
1891
  }
1654
1892
 
1655
1893
  async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
1894
+ await this.requireProjectAccess('plan.manage', iteration.projectId);
1656
1895
  const created = await this.store.createIteration(iteration);
1657
1896
  if (created.status === 'active') {
1658
1897
  const now = new Date().toISOString();
@@ -1671,8 +1910,9 @@ export class CriticalPathEngine {
1671
1910
  }
1672
1911
 
1673
1912
  async updateIteration(id: string, updates: Partial<Iteration>): Promise<Iteration | null> {
1674
- const existing = await this.store.getIteration(id);
1913
+ const existing = await this.getIteration(id);
1675
1914
  if (!existing) return null;
1915
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1676
1916
 
1677
1917
  const updated = await this.store.updateIteration(id, updates);
1678
1918
  if (updated) {
@@ -1705,6 +1945,9 @@ export class CriticalPathEngine {
1705
1945
  }
1706
1946
 
1707
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);
1708
1951
  return this.store.deleteIteration(id);
1709
1952
  }
1710
1953
 
@@ -1713,6 +1956,7 @@ export class CriticalPathEngine {
1713
1956
  projectId: string,
1714
1957
  options: CPMOptions = {}
1715
1958
  ): Promise<CriticalPathAnalysis> {
1959
+ await this.requireProjectAccess('project.read', projectId);
1716
1960
  const project = await this.store.getProject(projectId);
1717
1961
  const tasks = await this.store.getTasks(projectId);
1718
1962
  const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
@@ -1735,6 +1979,7 @@ export class CriticalPathEngine {
1735
1979
  projectId: string,
1736
1980
  options: TimelineLadderOptions = {}
1737
1981
  ): Promise<TimelineLadder> {
1982
+ await this.requireProjectAccess('project.read', projectId);
1738
1983
  const project = await this.store.getProject(projectId);
1739
1984
  if (!project) {
1740
1985
  throw new NotFoundError(`Project with ID "${projectId}" not found.`);
@@ -1791,7 +2036,7 @@ export class CriticalPathEngine {
1791
2036
  }
1792
2037
 
1793
2038
  async getTaskLadder(taskId: string): Promise<TaskLadderView | null> {
1794
- const task = await this.store.getTask(taskId);
2039
+ const task = await this.getTask(taskId);
1795
2040
  if (!task) return null;
1796
2041
 
1797
2042
  const project = await this.store.getProject(task.projectId);
@@ -1815,7 +2060,7 @@ export class CriticalPathEngine {
1815
2060
  }
1816
2061
 
1817
2062
  async getTaskMetrics(taskId: string, options?: MetricOptions): Promise<TaskMetrics | null> {
1818
- const task = await this.store.getTask(taskId);
2063
+ const task = await this.getTask(taskId);
1819
2064
  if (!task) return null;
1820
2065
 
1821
2066
  const [activities, timeEntries, project, workflow] = await Promise.all([
@@ -1834,7 +2079,7 @@ export class CriticalPathEngine {
1834
2079
  }
1835
2080
 
1836
2081
  async getTaskProgressHistory(taskId: string, options?: MetricOptions): Promise<TaskProgressHistory | null> {
1837
- const task = await this.store.getTask(taskId);
2082
+ const task = await this.getTask(taskId);
1838
2083
  if (!task) return null;
1839
2084
 
1840
2085
  const [activities, project, workflow] = await Promise.all([
@@ -1855,8 +2100,9 @@ export class CriticalPathEngine {
1855
2100
  projectId?: string,
1856
2101
  options: WorkloadDistributionOptions = {}
1857
2102
  ): Promise<WorkloadDistribution> {
1858
- const tasks = await this.store.getTasks(projectId);
1859
- 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();
1860
2106
  const project = projectId ? await this.store.getProject(projectId) : undefined;
1861
2107
  const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
1862
2108