@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
@@ -7,7 +7,7 @@ import { DomainEventBus } from '../domain/events.js';
7
7
  import { validateAttachmentUrl } from '../domain/entities.js';
8
8
  import { validateCustomFieldValues } from '../domain/custom-fields.js';
9
9
  import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
10
- import { ValidationError, NotFoundError } from '../domain/errors.js';
10
+ import { ValidationError, NotFoundError, ForbiddenError } from '../domain/errors.js';
11
11
  import { calculateCPM } from '../domain/cpm.js';
12
12
  import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
13
13
  import { calculateTaskMetrics, reconstructTaskProgressHistory } from '../domain/metrics.js';
@@ -21,6 +21,8 @@ export class CriticalPathEngine {
21
21
  ready = Promise.resolve();
22
22
  /** The identity mutations are attributed to. Set only on views returned by `withActor`. */
23
23
  actor;
24
+ /** Set on internal views that keep the actor for attribution but skip authorization. */
25
+ authorizationBypassed;
24
26
  constructor(config = {}) {
25
27
  this.config = config;
26
28
  this.store = typeof config.store === 'object' && config.store !== null
@@ -53,6 +55,102 @@ export class CriticalPathEngine {
53
55
  actorIdOr(fallback) {
54
56
  return this.actor?.userId ?? fallback;
55
57
  }
58
+ // --- Authorization (applies only to withActor views; the base engine is trusted) ---
59
+ /**
60
+ * True when calls must be checked: on a withActor view (not an internal elevated view) with a
61
+ * policy or a tenant to enforce. Otherwise there is nothing to check, so lookups are skipped.
62
+ */
63
+ get enforcing() {
64
+ return !!this.actor && !this.authorizationBypassed && (!!this.config.authorize || !!this.actor.tenantId);
65
+ }
66
+ /** A view that keeps the actor for attribution but skips checks, for cascades already authorized. */
67
+ elevated() {
68
+ if (!this.enforcing)
69
+ return this;
70
+ const view = Object.create(this);
71
+ Object.defineProperty(view, 'authorizationBypassed', { value: true });
72
+ return view;
73
+ }
74
+ inActorTenant(entity) {
75
+ const tenant = this.actor?.tenantId;
76
+ return !tenant || entity?.tenantId === tenant;
77
+ }
78
+ async isAllowed(action, project, resource) {
79
+ if (!this.enforcing)
80
+ return true;
81
+ if (project && !this.inActorTenant(project))
82
+ return false;
83
+ const policy = this.config.authorize;
84
+ return policy ? await policy({ actor: this.actor, action, project, resource }) : true;
85
+ }
86
+ /** The project if the actor may read it, otherwise null (callers report "not found"). */
87
+ async readableProject(projectId) {
88
+ if (!projectId)
89
+ return null;
90
+ const project = await this.store.getProject(projectId);
91
+ if (!project)
92
+ return null;
93
+ return (await this.isAllowed('project.read', project)) ? project : null;
94
+ }
95
+ async canReadProject(projectId) {
96
+ if (!this.enforcing)
97
+ return true;
98
+ return (await this.readableProject(projectId)) !== null;
99
+ }
100
+ /**
101
+ * Ensures the actor may perform `action` in `projectId`. Projects the actor cannot read (or
102
+ * that belong to another tenant) are reported as not found so their existence is not revealed.
103
+ */
104
+ async requireProjectAccess(action, projectId, resource) {
105
+ if (!this.enforcing)
106
+ return;
107
+ const project = await this.readableProject(projectId);
108
+ if (!project)
109
+ throw new NotFoundError(`Project "${projectId}" not found.`);
110
+ if (action !== 'project.read' && !(await this.isAllowed(action, project, resource))) {
111
+ throw new ForbiddenError(`Not allowed to ${action} in project "${projectId}".`);
112
+ }
113
+ }
114
+ async requireWorkspaceAccess(action) {
115
+ if (!(await this.isAllowed(action)))
116
+ throw new ForbiddenError(`Not allowed to ${action}.`);
117
+ }
118
+ /** Keeps only items in projects the actor may read. Items with no project are hidden on views. */
119
+ async filterReadable(items, projectIdOf) {
120
+ if (!this.enforcing)
121
+ return items;
122
+ const readable = new Map();
123
+ const result = [];
124
+ for (const item of items) {
125
+ const projectId = await projectIdOf(item);
126
+ if (!projectId)
127
+ continue;
128
+ if (!readable.has(projectId))
129
+ readable.set(projectId, await this.canReadProject(projectId));
130
+ if (readable.get(projectId))
131
+ result.push(item);
132
+ }
133
+ return result;
134
+ }
135
+ async projectIdOfTask(taskId) {
136
+ return taskId ? (await this.store.getTask(taskId))?.projectId : undefined;
137
+ }
138
+ async projectIdOfComment(commentId) {
139
+ if (!commentId)
140
+ return undefined;
141
+ const comment = await this.store.getComment(commentId);
142
+ return this.projectIdOfTask(comment?.taskId);
143
+ }
144
+ async projectIdOfAttachment(a) {
145
+ return a.projectId ?? (await this.projectIdOfTask(a.taskId)) ?? (await this.projectIdOfComment(a.commentId));
146
+ }
147
+ /** Strips fields only the engine may set (tenancy) from updates made through a view. */
148
+ withoutTenant(updates) {
149
+ if (!this.actor || !('tenantId' in updates))
150
+ return updates;
151
+ const { tenantId: _ignored, ...rest } = updates;
152
+ return rest;
153
+ }
56
154
  async seedInitialData(data) {
57
155
  if (data.workflows) {
58
156
  for (const wf of data.workflows) {
@@ -92,13 +190,16 @@ export class CriticalPathEngine {
92
190
  }
93
191
  // --- Workflows ---
94
192
  async getWorkflows() {
95
- return this.store.getWorkflows();
193
+ const workflows = await this.store.getWorkflows();
194
+ return workflows.filter((w) => this.inActorTenant(w));
96
195
  }
97
196
  async getWorkflow(id) {
98
- return this.store.getWorkflow(id);
197
+ const workflow = await this.store.getWorkflow(id);
198
+ return workflow && this.inActorTenant(workflow) ? workflow : null;
99
199
  }
100
200
  async createWorkflow(workflow) {
101
- const created = await this.store.createWorkflow(workflow);
201
+ await this.requireWorkspaceAccess('workspace.manage');
202
+ const created = await this.store.createWorkflow({ ...workflow, tenantId: this.actor ? this.actor.tenantId : workflow.tenantId });
102
203
  const now = new Date().toISOString();
103
204
  const event = {
104
205
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -118,9 +219,11 @@ export class CriticalPathEngine {
118
219
  return created;
119
220
  }
120
221
  async updateWorkflow(id, updates) {
121
- const existing = await this.store.getWorkflow(id);
222
+ const existing = await this.getWorkflow(id);
122
223
  if (!existing)
123
224
  return null;
225
+ await this.requireWorkspaceAccess('workspace.manage');
226
+ updates = this.withoutTenant(updates);
124
227
  const updated = await this.store.updateWorkflow(id, updates);
125
228
  if (updated) {
126
229
  const now = new Date().toISOString();
@@ -143,9 +246,10 @@ export class CriticalPathEngine {
143
246
  return updated;
144
247
  }
145
248
  async deleteWorkflow(id) {
146
- const existing = await this.store.getWorkflow(id);
249
+ const existing = await this.getWorkflow(id);
147
250
  if (!existing)
148
251
  return false;
252
+ await this.requireWorkspaceAccess('workspace.manage');
149
253
  const deleted = await this.store.deleteWorkflow(id);
150
254
  if (deleted) {
151
255
  const now = new Date().toISOString();
@@ -176,7 +280,8 @@ export class CriticalPathEngine {
176
280
  if (wf)
177
281
  return wf;
178
282
  }
179
- const workflows = await this.store.getWorkflows();
283
+ // Only fall back to workflows from the project's own tenant.
284
+ const workflows = (await this.store.getWorkflows()).filter((w) => w.tenantId === project?.tenantId);
180
285
  const defaultWf = workflows.find((w) => w.isDefault);
181
286
  if (defaultWf)
182
287
  return defaultWf;
@@ -185,21 +290,21 @@ export class CriticalPathEngine {
185
290
  return DEFAULT_SOFTWARE_WORKFLOW;
186
291
  }
187
292
  async getAllowedTaskTransitions(taskId) {
188
- const task = await this.store.getTask(taskId);
293
+ const task = await this.getTask(taskId);
189
294
  if (!task)
190
295
  return [];
191
296
  const workflow = await this.resolveProjectWorkflow(task.projectId);
192
297
  return getAllowedTransitions(workflow || undefined, task.status);
193
298
  }
194
299
  async getAllowedNextTaskTransitions(taskId) {
195
- const task = await this.store.getTask(taskId);
300
+ const task = await this.getTask(taskId);
196
301
  if (!task)
197
302
  return [];
198
303
  const workflow = await this.resolveProjectWorkflow(task.projectId);
199
304
  return getAllowedNextStatuses(workflow || undefined, task.status);
200
305
  }
201
306
  async getAllowedPreviousTaskTransitions(taskId) {
202
- const task = await this.store.getTask(taskId);
307
+ const task = await this.getTask(taskId);
203
308
  if (!task)
204
309
  return [];
205
310
  const workflow = await this.resolveProjectWorkflow(task.projectId);
@@ -207,13 +312,23 @@ export class CriticalPathEngine {
207
312
  }
208
313
  // --- Projects ---
209
314
  async getProjects() {
210
- return this.store.getProjects();
315
+ const projects = await this.store.getProjects();
316
+ return this.filterReadable(projects, (p) => p.id);
211
317
  }
212
318
  async getProject(id) {
213
- return this.store.getProject(id);
319
+ if (!this.enforcing)
320
+ return this.store.getProject(id);
321
+ return this.readableProject(id);
214
322
  }
215
323
  async createProject(project) {
216
- const created = await this.store.createProject(project);
324
+ await this.requireWorkspaceAccess('project.create');
325
+ // Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
326
+ const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
327
+ // The creator administers the project they create.
328
+ if (this.actor && this.actor.userId !== 'anonymous' && !input.members?.some((m) => m.userId === this.actor.userId)) {
329
+ input.members = [...(input.members ?? []), { userId: this.actor.userId, role: 'admin' }];
330
+ }
331
+ const created = await this.store.createProject(input);
217
332
  const now = new Date().toISOString();
218
333
  const event = {
219
334
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -234,9 +349,13 @@ export class CriticalPathEngine {
234
349
  return created;
235
350
  }
236
351
  async updateProject(id, updates) {
237
- const existing = await this.store.getProject(id);
352
+ const existing = await this.getProject(id);
238
353
  if (!existing)
239
354
  return null;
355
+ await this.requireProjectAccess('project.update', id);
356
+ if ('members' in updates)
357
+ await this.requireProjectAccess('project.manage_members', id);
358
+ updates = this.withoutTenant(updates);
240
359
  const updated = await this.store.updateProject(id, updates);
241
360
  if (updated) {
242
361
  const now = new Date().toISOString();
@@ -264,13 +383,16 @@ export class CriticalPathEngine {
264
383
  * hooks run and `task.deleted` events fire before `project.deleted` is published.
265
384
  */
266
385
  async deleteProject(id) {
267
- const existing = await this.store.getProject(id);
386
+ const existing = await this.getProject(id);
268
387
  if (!existing)
269
388
  return false;
389
+ await this.requireProjectAccess('project.delete', id);
390
+ // Deleting the project implies deleting its tasks; skip per-task checks for the cascade.
391
+ const cascade = this.elevated();
270
392
  const tasks = await this.store.getTasks(id);
271
393
  const deletedTaskIds = [];
272
394
  for (const task of tasks) {
273
- if (await this.deleteTask(task.id)) {
395
+ if (await cascade.deleteTask(task.id)) {
274
396
  deletedTaskIds.push(task.id);
275
397
  }
276
398
  }
@@ -298,12 +420,19 @@ export class CriticalPathEngine {
298
420
  }
299
421
  // --- Tasks ---
300
422
  async getTasks(projectId) {
301
- return this.store.getTasks(projectId);
423
+ if (projectId && !(await this.canReadProject(projectId)))
424
+ return [];
425
+ const tasks = await this.store.getTasks(projectId);
426
+ return projectId ? tasks : this.filterReadable(tasks, (t) => t.projectId);
302
427
  }
303
428
  async getTask(id) {
304
- return this.store.getTask(id);
429
+ const task = await this.store.getTask(id);
430
+ if (!task)
431
+ return null;
432
+ return (await this.canReadProject(task.projectId)) ? task : null;
305
433
  }
306
434
  async createTask(taskInput) {
435
+ await this.requireProjectAccess('task.create', taskInput.projectId);
307
436
  if (this.actor && !taskInput.reporterId) {
308
437
  taskInput = { ...taskInput, reporterId: this.actor.userId };
309
438
  }
@@ -389,34 +518,17 @@ export class CriticalPathEngine {
389
518
  return created;
390
519
  }
391
520
  async updateTask(id, updates, options) {
392
- const existing = await this.store.getTask(id);
521
+ const existing = await this.getTask(id);
393
522
  if (!existing)
394
523
  return null;
395
- // On a withActor view, the scoped actor replaces any identity claimed in the payload.
396
- // Explicit options from trusted server-side code still take precedence.
524
+ await this.requireProjectAccess('task.update', existing.projectId);
525
+ // Identity comes from trusted options or the withActor view, never from the update payload.
397
526
  options = options ?? (this.actor ? { actor: this.actor } : undefined);
398
- const claimed = this.actor ? undefined : updates;
399
- const actorId = options?.actorId ||
400
- claimed?.actorId ||
401
- options?.actor?.userId ||
402
- claimed?.actor?.userId ||
403
- 'system';
404
- const actorName = options?.actorName ||
405
- claimed?.actorName ||
406
- options?.actor?.username ||
407
- claimed?.actor?.username ||
408
- (actorId === 'system' ? 'System' : undefined);
409
- const actorType = options?.actorType ||
410
- claimed?.actorType ||
411
- options?.actor?.actorType ||
412
- claimed?.actor?.actorType ||
413
- (actorId === 'system' ? 'system' : 'user');
414
- const actorObj = options?.actor || claimed?.actor || {
415
- userId: actorId,
416
- username: actorName,
417
- actorType
418
- };
419
- const { actorId: _aId, actorName: _aName, actorType: _aType, actor: _aObj, ...taskUpdates } = updates;
527
+ const actorId = options?.actorId || options?.actor?.userId || 'system';
528
+ const actorName = options?.actorName || options?.actor?.username || (actorId === 'system' ? 'System' : undefined);
529
+ const actorType = options?.actorType || options?.actor?.actorType || (actorId === 'system' ? 'system' : 'user');
530
+ const actorObj = options?.actor || { userId: actorId, username: actorName, actorType };
531
+ const taskUpdates = updates;
420
532
  const project = await this.store.getProject(existing.projectId);
421
533
  const workflow = await this.resolveProjectWorkflow(existing.projectId);
422
534
  // Validate workflow transition invariant
@@ -766,9 +878,10 @@ export class CriticalPathEngine {
766
878
  }
767
879
  }
768
880
  async deleteTask(id) {
769
- const existing = await this.store.getTask(id);
881
+ const existing = await this.getTask(id);
770
882
  if (!existing)
771
883
  return false;
884
+ await this.requireProjectAccess('task.delete', existing.projectId);
772
885
  await this.plugins.runBeforeTaskDelete(id);
773
886
  const deleted = await this.store.deleteTask(id);
774
887
  if (deleted) {
@@ -800,6 +913,14 @@ export class CriticalPathEngine {
800
913
  }
801
914
  // --- Task Dependencies & Graph ---
802
915
  async addDependency(dep) {
916
+ if (this.enforcing) {
917
+ const [task, upstream] = await Promise.all([this.getTask(dep.taskId), this.getTask(dep.dependsOnTaskId)]);
918
+ if (!task)
919
+ throw new NotFoundError(`Task "${dep.taskId}" not found.`);
920
+ if (!upstream)
921
+ throw new NotFoundError(`Task "${dep.dependsOnTaskId}" not found.`);
922
+ await this.requireProjectAccess('task.update', task.projectId);
923
+ }
803
924
  // Enforce Directed Acyclic Graph (DAG) Invariant
804
925
  const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
805
926
  const cycleCheck = detectDependencyCycle(reachableDeps, dep);
@@ -848,6 +969,9 @@ export class CriticalPathEngine {
848
969
  return Array.from(edges.values());
849
970
  }
850
971
  async getTaskDependencyGraph(taskId) {
972
+ if (this.enforcing && !(await this.getTask(taskId))) {
973
+ return { taskId, upstreamTasks: [], downstreamTasks: [], dependencies: [] };
974
+ }
851
975
  const dependencies = await this.store.getDependencies(taskId);
852
976
  const upstreamTaskIds = new Set();
853
977
  const downstreamTaskIds = new Set();
@@ -859,8 +983,8 @@ export class CriticalPathEngine {
859
983
  downstreamTaskIds.add(dep.taskId);
860
984
  }
861
985
  }
862
- const upstreamTasks = (await Promise.all(Array.from(upstreamTaskIds).map((id) => this.store.getTask(id)))).filter((t) => t !== null);
863
- const downstreamTasks = (await Promise.all(Array.from(downstreamTaskIds).map((id) => this.store.getTask(id)))).filter((t) => t !== null);
986
+ const upstreamTasks = (await Promise.all(Array.from(upstreamTaskIds).map((id) => this.getTask(id)))).filter((t) => t !== null);
987
+ const downstreamTasks = (await Promise.all(Array.from(downstreamTaskIds).map((id) => this.getTask(id)))).filter((t) => t !== null);
864
988
  return {
865
989
  taskId,
866
990
  upstreamTasks,
@@ -889,7 +1013,12 @@ export class CriticalPathEngine {
889
1013
  if (!Number.isFinite(entry.hours) || entry.hours <= 0) {
890
1014
  throw new ValidationError('Logged hours must be a positive number.');
891
1015
  }
892
- const task = await this.store.getTask(entry.taskId);
1016
+ const task = await this.getTask(entry.taskId);
1017
+ if (this.enforcing) {
1018
+ if (!task)
1019
+ throw new NotFoundError(`Task "${entry.taskId}" not found.`);
1020
+ await this.requireProjectAccess('time.log', task.projectId);
1021
+ }
893
1022
  if (task) {
894
1023
  const newLoggedHours = (task.loggedHours || 0) + entry.hours;
895
1024
  const newActualHours = (task.actualHours || 0) + entry.hours;
@@ -920,15 +1049,35 @@ export class CriticalPathEngine {
920
1049
  }
921
1050
  // --- Comments ---
922
1051
  async getComments(taskId) {
1052
+ if (this.enforcing && !(await this.getTask(taskId)))
1053
+ return [];
923
1054
  return this.store.getComments(taskId);
924
1055
  }
925
1056
  async getComment(id) {
926
- return this.store.getComment(id);
1057
+ const comment = await this.store.getComment(id);
1058
+ if (!comment)
1059
+ return null;
1060
+ return (await this.canReadProject(await this.projectIdOfTask(comment.taskId))) ? comment : null;
927
1061
  }
928
- async addComment(comment) {
929
- if (this.actor) {
930
- comment = { ...comment, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' };
931
- }
1062
+ async getActivities(filter) {
1063
+ const activities = await this.store.getActivities(filter);
1064
+ return this.filterReadable(activities, (a) => a.projectId ?? this.projectIdOfTask(a.taskId));
1065
+ }
1066
+ async getTimeEntries(taskId) {
1067
+ if (this.enforcing && !(await this.getTask(taskId)))
1068
+ return [];
1069
+ return this.store.getTimeEntries(taskId);
1070
+ }
1071
+ async addComment(input) {
1072
+ if (this.enforcing) {
1073
+ const task = await this.getTask(input.taskId);
1074
+ if (!task)
1075
+ throw new NotFoundError(`Task "${input.taskId}" not found.`);
1076
+ await this.requireProjectAccess('comment.create', task.projectId);
1077
+ }
1078
+ const comment = this.actor
1079
+ ? { ...input, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' }
1080
+ : { ...input, authorId: input.authorId ?? 'system' };
932
1081
  const mentions = comment.mentions ?? extractMentions(comment.content);
933
1082
  const created = await this.store.addComment({ ...comment, mentions });
934
1083
  const now = new Date().toISOString();
@@ -945,9 +1094,13 @@ export class CriticalPathEngine {
945
1094
  return created;
946
1095
  }
947
1096
  async updateComment(id, updates) {
948
- const existing = await this.store.getComment(id);
1097
+ const existing = await this.getComment(id);
949
1098
  if (!existing)
950
1099
  return null;
1100
+ await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
1101
+ type: 'comment',
1102
+ ownerId: existing.authorId
1103
+ });
951
1104
  const mentions = updates.mentions ?? (updates.content ? extractMentions(updates.content) : undefined);
952
1105
  const toUpdate = mentions !== undefined ? { ...updates, mentions } : updates;
953
1106
  const updated = await this.store.updateComment(id, toUpdate);
@@ -967,9 +1120,13 @@ export class CriticalPathEngine {
967
1120
  return updated;
968
1121
  }
969
1122
  async deleteComment(id) {
970
- const existing = await this.store.getComment(id);
1123
+ const existing = await this.getComment(id);
971
1124
  if (!existing)
972
1125
  return false;
1126
+ await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
1127
+ type: 'comment',
1128
+ ownerId: existing.authorId
1129
+ });
973
1130
  const deleted = await this.store.deleteComment(id);
974
1131
  if (deleted) {
975
1132
  const now = new Date().toISOString();
@@ -986,12 +1143,12 @@ export class CriticalPathEngine {
986
1143
  }
987
1144
  return deleted;
988
1145
  }
989
- async addCommentReaction(commentId, reaction) {
990
- if (this.actor)
991
- reaction = { ...reaction, userId: this.actor.userId };
992
- const existing = await this.store.getComment(commentId);
1146
+ async addCommentReaction(commentId, input) {
1147
+ const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
1148
+ const existing = await this.getComment(commentId);
993
1149
  if (!existing)
994
1150
  return null;
1151
+ await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
995
1152
  let updated;
996
1153
  if (this.store.addReaction) {
997
1154
  updated = await this.store.addReaction(commentId, reaction);
@@ -1031,12 +1188,12 @@ export class CriticalPathEngine {
1031
1188
  this.dispatchWebhook('comment.reaction.added', { comment: updated, reaction });
1032
1189
  return updated;
1033
1190
  }
1034
- async removeCommentReaction(commentId, reaction) {
1035
- if (this.actor)
1036
- reaction = { ...reaction, userId: this.actor.userId };
1037
- const existing = await this.store.getComment(commentId);
1191
+ async removeCommentReaction(commentId, input) {
1192
+ const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
1193
+ const existing = await this.getComment(commentId);
1038
1194
  if (!existing)
1039
1195
  return null;
1196
+ await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
1040
1197
  let updated;
1041
1198
  if (this.store.removeReaction) {
1042
1199
  updated = await this.store.removeReaction(commentId, reaction);
@@ -1062,19 +1219,31 @@ export class CriticalPathEngine {
1062
1219
  }
1063
1220
  // --- Attachments ---
1064
1221
  async getAttachments(filter) {
1065
- const attachments = await this.store.getAttachments(filter);
1222
+ const attachments = await this.filterReadable(await this.store.getAttachments(filter), (a) => this.projectIdOfAttachment(a));
1066
1223
  if (filter?.artifactType) {
1067
1224
  return attachments.filter(a => a.artifactType === filter.artifactType);
1068
1225
  }
1069
1226
  return attachments;
1070
1227
  }
1071
1228
  async getAttachment(id) {
1072
- return this.store.getAttachment(id);
1073
- }
1074
- async createAttachment(attachment) {
1075
- if (this.actor) {
1076
- attachment = { ...attachment, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' };
1077
- }
1229
+ const attachment = await this.store.getAttachment(id);
1230
+ if (!attachment)
1231
+ return null;
1232
+ return (await this.canReadProject(await this.projectIdOfAttachment(attachment))) ? attachment : null;
1233
+ }
1234
+ async requireAttachmentCreate(link) {
1235
+ if (!this.enforcing)
1236
+ return;
1237
+ const projectId = await this.projectIdOfAttachment(link);
1238
+ if (!projectId)
1239
+ throw new ValidationError('Attachments must reference a project, task or comment.');
1240
+ await this.requireProjectAccess('attachment.create', projectId);
1241
+ }
1242
+ async createAttachment(input) {
1243
+ await this.requireAttachmentCreate(input);
1244
+ const attachment = this.actor
1245
+ ? { ...input, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' }
1246
+ : { ...input, uploaderId: input.uploaderId ?? 'system' };
1078
1247
  validateAttachmentUrl(attachment.url);
1079
1248
  const created = await this.store.createAttachment(attachment);
1080
1249
  const now = new Date().toISOString();
@@ -1091,9 +1260,13 @@ export class CriticalPathEngine {
1091
1260
  return created;
1092
1261
  }
1093
1262
  async deleteAttachment(id) {
1094
- const existing = await this.store.getAttachment(id);
1263
+ const existing = await this.getAttachment(id);
1095
1264
  if (!existing)
1096
1265
  return false;
1266
+ await this.requireProjectAccess('attachment.delete', await this.projectIdOfAttachment(existing), {
1267
+ type: 'attachment',
1268
+ ownerId: existing.uploaderId
1269
+ });
1097
1270
  if (existing.storageKey && this.fileStorage) {
1098
1271
  try {
1099
1272
  await this.fileStorage.delete(existing.storageKey);
@@ -1122,6 +1295,8 @@ export class CriticalPathEngine {
1122
1295
  if (!this.fileStorage) {
1123
1296
  throw new Error('No FileStorageAdapter configured in CriticalPathEngine. Pass "fileStorage" in config to enable direct file uploads.');
1124
1297
  }
1298
+ // Check before uploading so denied requests do not leave orphaned files behind.
1299
+ await this.requireAttachmentCreate(input);
1125
1300
  const uploadResult = await this.fileStorage.upload({
1126
1301
  filename: input.filename,
1127
1302
  data: input.data,
@@ -1175,17 +1350,33 @@ export class CriticalPathEngine {
1175
1350
  if (!this.fileStorage || !this.fileStorage.getPresignedUploadUrl) {
1176
1351
  throw new Error('Presigned uploads are not supported by the configured FileStorageAdapter.');
1177
1352
  }
1178
- return this.fileStorage.getPresignedUploadUrl(options);
1353
+ const { projectId, ...presign } = options;
1354
+ if (this.enforcing) {
1355
+ // Scoped callers may only write under their project's prefix.
1356
+ if (!projectId)
1357
+ throw new ValidationError('projectId is required for presigned uploads.');
1358
+ await this.requireProjectAccess('attachment.create', projectId);
1359
+ const prefix = `projects/${projectId}/`;
1360
+ const key = presign.storageKey.replace(/^\/+/, '');
1361
+ if (key.split('/').some((segment) => segment === '..' || segment === '.')) {
1362
+ throw new ValidationError('storageKey must not contain "." or ".." segments.');
1363
+ }
1364
+ presign.storageKey = key.startsWith(prefix) ? key : prefix + key;
1365
+ }
1366
+ return this.fileStorage.getPresignedUploadUrl(presign);
1179
1367
  }
1180
1368
  // --- Teams ---
1181
1369
  async getTeams() {
1182
- return this.store.getTeams();
1370
+ const teams = await this.store.getTeams();
1371
+ return teams.filter((t) => this.inActorTenant(t));
1183
1372
  }
1184
1373
  async getTeam(id) {
1185
- return this.store.getTeam(id);
1374
+ const team = await this.store.getTeam(id);
1375
+ return team && this.inActorTenant(team) ? team : null;
1186
1376
  }
1187
1377
  async createTeam(team) {
1188
- const created = await this.store.createTeam(team);
1378
+ await this.requireWorkspaceAccess('workspace.manage');
1379
+ const created = await this.store.createTeam({ ...team, tenantId: this.actor ? this.actor.tenantId : team.tenantId });
1189
1380
  const now = new Date().toISOString();
1190
1381
  const event = {
1191
1382
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -1200,19 +1391,31 @@ export class CriticalPathEngine {
1200
1391
  return created;
1201
1392
  }
1202
1393
  async updateTeam(id, updates) {
1203
- return this.store.updateTeam(id, updates);
1394
+ if (!(await this.getTeam(id)))
1395
+ return null;
1396
+ await this.requireWorkspaceAccess('workspace.manage');
1397
+ return this.store.updateTeam(id, this.withoutTenant(updates));
1204
1398
  }
1205
1399
  async deleteTeam(id) {
1400
+ if (!(await this.getTeam(id)))
1401
+ return false;
1402
+ await this.requireWorkspaceAccess('workspace.manage');
1206
1403
  return this.store.deleteTeam(id);
1207
1404
  }
1208
1405
  // --- Containers ---
1209
1406
  async getContainers(projectId) {
1407
+ if (!(await this.canReadProject(projectId)))
1408
+ return [];
1210
1409
  return this.store.getContainers(projectId);
1211
1410
  }
1212
1411
  async getContainer(id) {
1213
- return this.store.getContainer(id);
1412
+ const container = await this.store.getContainer(id);
1413
+ if (!container)
1414
+ return null;
1415
+ return (await this.canReadProject(container.projectId)) ? container : null;
1214
1416
  }
1215
1417
  async createContainer(container) {
1418
+ await this.requireProjectAccess('plan.manage', container.projectId);
1216
1419
  const created = await this.store.createContainer(container);
1217
1420
  const now = new Date().toISOString();
1218
1421
  const event = {
@@ -1228,19 +1431,33 @@ export class CriticalPathEngine {
1228
1431
  return created;
1229
1432
  }
1230
1433
  async updateContainer(id, updates) {
1434
+ const existing = await this.getContainer(id);
1435
+ if (!existing)
1436
+ return null;
1437
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1231
1438
  return this.store.updateContainer(id, updates);
1232
1439
  }
1233
1440
  async deleteContainer(id) {
1441
+ const existing = await this.getContainer(id);
1442
+ if (!existing)
1443
+ return false;
1444
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1234
1445
  return this.store.deleteContainer(id);
1235
1446
  }
1236
1447
  // --- Deliverables ---
1237
1448
  async getDeliverables(projectId) {
1449
+ if (!(await this.canReadProject(projectId)))
1450
+ return [];
1238
1451
  return this.store.getDeliverables(projectId);
1239
1452
  }
1240
1453
  async getDeliverable(id) {
1241
- return this.store.getDeliverable(id);
1454
+ const deliverable = await this.store.getDeliverable(id);
1455
+ if (!deliverable)
1456
+ return null;
1457
+ return (await this.canReadProject(deliverable.projectId)) ? deliverable : null;
1242
1458
  }
1243
1459
  async createDeliverable(input) {
1460
+ await this.requireProjectAccess('plan.manage', input.projectId);
1244
1461
  const project = await this.store.getProject(input.projectId);
1245
1462
  if (project?.customFieldDefinitions && input.customFields) {
1246
1463
  validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
@@ -1271,9 +1488,10 @@ export class CriticalPathEngine {
1271
1488
  return created;
1272
1489
  }
1273
1490
  async updateDeliverable(id, updates) {
1274
- const existing = await this.store.getDeliverable(id);
1491
+ const existing = await this.getDeliverable(id);
1275
1492
  if (!existing)
1276
1493
  return null;
1494
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1277
1495
  if (updates.status && updates.status === 'delivered' && !existing.deliveredAt && !updates.deliveredAt) {
1278
1496
  updates.deliveredAt = new Date().toISOString();
1279
1497
  }
@@ -1320,9 +1538,10 @@ export class CriticalPathEngine {
1320
1538
  return updated;
1321
1539
  }
1322
1540
  async deleteDeliverable(id) {
1323
- const existing = await this.store.getDeliverable(id);
1541
+ const existing = await this.getDeliverable(id);
1324
1542
  if (!existing)
1325
1543
  return false;
1544
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1326
1545
  const deleted = await this.store.deleteDeliverable(id);
1327
1546
  if (deleted) {
1328
1547
  const now = new Date().toISOString();
@@ -1346,7 +1565,7 @@ export class CriticalPathEngine {
1346
1565
  return deleted;
1347
1566
  }
1348
1567
  async getDeliverableSummary(deliverableId) {
1349
- const deliverable = await this.store.getDeliverable(deliverableId);
1568
+ const deliverable = await this.getDeliverable(deliverableId);
1350
1569
  if (!deliverable)
1351
1570
  return null;
1352
1571
  const project = await this.store.getProject(deliverable.projectId);
@@ -1391,12 +1610,18 @@ export class CriticalPathEngine {
1391
1610
  }
1392
1611
  // --- Iterations ---
1393
1612
  async getIterations(projectId) {
1613
+ if (!(await this.canReadProject(projectId)))
1614
+ return [];
1394
1615
  return this.store.getIterations(projectId);
1395
1616
  }
1396
1617
  async getIteration(id) {
1397
- return this.store.getIteration(id);
1618
+ const iteration = await this.store.getIteration(id);
1619
+ if (!iteration)
1620
+ return null;
1621
+ return (await this.canReadProject(iteration.projectId)) ? iteration : null;
1398
1622
  }
1399
1623
  async createIteration(iteration) {
1624
+ await this.requireProjectAccess('plan.manage', iteration.projectId);
1400
1625
  const created = await this.store.createIteration(iteration);
1401
1626
  if (created.status === 'active') {
1402
1627
  const now = new Date().toISOString();
@@ -1414,9 +1639,10 @@ export class CriticalPathEngine {
1414
1639
  return created;
1415
1640
  }
1416
1641
  async updateIteration(id, updates) {
1417
- const existing = await this.store.getIteration(id);
1642
+ const existing = await this.getIteration(id);
1418
1643
  if (!existing)
1419
1644
  return null;
1645
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1420
1646
  const updated = await this.store.updateIteration(id, updates);
1421
1647
  if (updated) {
1422
1648
  const now = new Date().toISOString();
@@ -1448,10 +1674,15 @@ export class CriticalPathEngine {
1448
1674
  return updated;
1449
1675
  }
1450
1676
  async deleteIteration(id) {
1677
+ const existing = await this.getIteration(id);
1678
+ if (!existing)
1679
+ return false;
1680
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1451
1681
  return this.store.deleteIteration(id);
1452
1682
  }
1453
1683
  // --- Ladder of Abstraction & Critical Path Method ---
1454
1684
  async calculateCriticalPath(projectId, options = {}) {
1685
+ await this.requireProjectAccess('project.read', projectId);
1455
1686
  const project = await this.store.getProject(projectId);
1456
1687
  const tasks = await this.store.getTasks(projectId);
1457
1688
  const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
@@ -1470,6 +1701,7 @@ export class CriticalPathEngine {
1470
1701
  return calculateCPM(projectId, tasks, dependencies, { schedule, projectStartDate });
1471
1702
  }
1472
1703
  async getTimelineLadder(projectId, options = {}) {
1704
+ await this.requireProjectAccess('project.read', projectId);
1473
1705
  const project = await this.store.getProject(projectId);
1474
1706
  if (!project) {
1475
1707
  throw new NotFoundError(`Project with ID "${projectId}" not found.`);
@@ -1516,7 +1748,7 @@ export class CriticalPathEngine {
1516
1748
  }, options);
1517
1749
  }
1518
1750
  async getTaskLadder(taskId) {
1519
- const task = await this.store.getTask(taskId);
1751
+ const task = await this.getTask(taskId);
1520
1752
  if (!task)
1521
1753
  return null;
1522
1754
  const project = await this.store.getProject(task.projectId);
@@ -1538,7 +1770,7 @@ export class CriticalPathEngine {
1538
1770
  };
1539
1771
  }
1540
1772
  async getTaskMetrics(taskId, options) {
1541
- const task = await this.store.getTask(taskId);
1773
+ const task = await this.getTask(taskId);
1542
1774
  if (!task)
1543
1775
  return null;
1544
1776
  const [activities, timeEntries, project, workflow] = await Promise.all([
@@ -1554,7 +1786,7 @@ export class CriticalPathEngine {
1554
1786
  });
1555
1787
  }
1556
1788
  async getTaskProgressHistory(taskId, options) {
1557
- const task = await this.store.getTask(taskId);
1789
+ const task = await this.getTask(taskId);
1558
1790
  if (!task)
1559
1791
  return null;
1560
1792
  const [activities, project, workflow] = await Promise.all([
@@ -1569,8 +1801,10 @@ export class CriticalPathEngine {
1569
1801
  });
1570
1802
  }
1571
1803
  async getWorkloadDistribution(projectId, options = {}) {
1572
- const tasks = await this.store.getTasks(projectId);
1573
- const teams = await this.store.getTeams();
1804
+ if (projectId)
1805
+ await this.requireProjectAccess('project.read', projectId);
1806
+ const tasks = await this.getTasks(projectId);
1807
+ const teams = await this.getTeams();
1574
1808
  const project = projectId ? await this.store.getProject(projectId) : undefined;
1575
1809
  const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
1576
1810
  const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));