@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
@@ -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,9 +518,10 @@ 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;
524
+ await this.requireProjectAccess('task.update', existing.projectId);
395
525
  // Identity comes from trusted options or the withActor view, never from the update payload.
396
526
  options = options ?? (this.actor ? { actor: this.actor } : undefined);
397
527
  const actorId = options?.actorId || options?.actor?.userId || 'system';
@@ -748,9 +878,10 @@ export class CriticalPathEngine {
748
878
  }
749
879
  }
750
880
  async deleteTask(id) {
751
- const existing = await this.store.getTask(id);
881
+ const existing = await this.getTask(id);
752
882
  if (!existing)
753
883
  return false;
884
+ await this.requireProjectAccess('task.delete', existing.projectId);
754
885
  await this.plugins.runBeforeTaskDelete(id);
755
886
  const deleted = await this.store.deleteTask(id);
756
887
  if (deleted) {
@@ -782,6 +913,14 @@ export class CriticalPathEngine {
782
913
  }
783
914
  // --- Task Dependencies & Graph ---
784
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
+ }
785
924
  // Enforce Directed Acyclic Graph (DAG) Invariant
786
925
  const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
787
926
  const cycleCheck = detectDependencyCycle(reachableDeps, dep);
@@ -830,6 +969,9 @@ export class CriticalPathEngine {
830
969
  return Array.from(edges.values());
831
970
  }
832
971
  async getTaskDependencyGraph(taskId) {
972
+ if (this.enforcing && !(await this.getTask(taskId))) {
973
+ return { taskId, upstreamTasks: [], downstreamTasks: [], dependencies: [] };
974
+ }
833
975
  const dependencies = await this.store.getDependencies(taskId);
834
976
  const upstreamTaskIds = new Set();
835
977
  const downstreamTaskIds = new Set();
@@ -841,8 +983,8 @@ export class CriticalPathEngine {
841
983
  downstreamTaskIds.add(dep.taskId);
842
984
  }
843
985
  }
844
- const upstreamTasks = (await Promise.all(Array.from(upstreamTaskIds).map((id) => this.store.getTask(id)))).filter((t) => t !== null);
845
- const downstreamTasks = (await Promise.all(Array.from(downstreamTaskIds).map((id) => this.store.getTask(id)))).filter((t) => t !== null);
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);
846
988
  return {
847
989
  taskId,
848
990
  upstreamTasks,
@@ -871,7 +1013,12 @@ export class CriticalPathEngine {
871
1013
  if (!Number.isFinite(entry.hours) || entry.hours <= 0) {
872
1014
  throw new ValidationError('Logged hours must be a positive number.');
873
1015
  }
874
- 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
+ }
875
1022
  if (task) {
876
1023
  const newLoggedHours = (task.loggedHours || 0) + entry.hours;
877
1024
  const newActualHours = (task.actualHours || 0) + entry.hours;
@@ -902,12 +1049,32 @@ export class CriticalPathEngine {
902
1049
  }
903
1050
  // --- Comments ---
904
1051
  async getComments(taskId) {
1052
+ if (this.enforcing && !(await this.getTask(taskId)))
1053
+ return [];
905
1054
  return this.store.getComments(taskId);
906
1055
  }
907
1056
  async getComment(id) {
908
- 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;
1061
+ }
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);
909
1070
  }
910
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
+ }
911
1078
  const comment = this.actor
912
1079
  ? { ...input, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' }
913
1080
  : { ...input, authorId: input.authorId ?? 'system' };
@@ -927,9 +1094,13 @@ export class CriticalPathEngine {
927
1094
  return created;
928
1095
  }
929
1096
  async updateComment(id, updates) {
930
- const existing = await this.store.getComment(id);
1097
+ const existing = await this.getComment(id);
931
1098
  if (!existing)
932
1099
  return null;
1100
+ await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
1101
+ type: 'comment',
1102
+ ownerId: existing.authorId
1103
+ });
933
1104
  const mentions = updates.mentions ?? (updates.content ? extractMentions(updates.content) : undefined);
934
1105
  const toUpdate = mentions !== undefined ? { ...updates, mentions } : updates;
935
1106
  const updated = await this.store.updateComment(id, toUpdate);
@@ -949,9 +1120,13 @@ export class CriticalPathEngine {
949
1120
  return updated;
950
1121
  }
951
1122
  async deleteComment(id) {
952
- const existing = await this.store.getComment(id);
1123
+ const existing = await this.getComment(id);
953
1124
  if (!existing)
954
1125
  return false;
1126
+ await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
1127
+ type: 'comment',
1128
+ ownerId: existing.authorId
1129
+ });
955
1130
  const deleted = await this.store.deleteComment(id);
956
1131
  if (deleted) {
957
1132
  const now = new Date().toISOString();
@@ -970,9 +1145,10 @@ export class CriticalPathEngine {
970
1145
  }
971
1146
  async addCommentReaction(commentId, input) {
972
1147
  const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
973
- const existing = await this.store.getComment(commentId);
1148
+ const existing = await this.getComment(commentId);
974
1149
  if (!existing)
975
1150
  return null;
1151
+ await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
976
1152
  let updated;
977
1153
  if (this.store.addReaction) {
978
1154
  updated = await this.store.addReaction(commentId, reaction);
@@ -1014,9 +1190,10 @@ export class CriticalPathEngine {
1014
1190
  }
1015
1191
  async removeCommentReaction(commentId, input) {
1016
1192
  const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
1017
- const existing = await this.store.getComment(commentId);
1193
+ const existing = await this.getComment(commentId);
1018
1194
  if (!existing)
1019
1195
  return null;
1196
+ await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
1020
1197
  let updated;
1021
1198
  if (this.store.removeReaction) {
1022
1199
  updated = await this.store.removeReaction(commentId, reaction);
@@ -1042,16 +1219,28 @@ export class CriticalPathEngine {
1042
1219
  }
1043
1220
  // --- Attachments ---
1044
1221
  async getAttachments(filter) {
1045
- const attachments = await this.store.getAttachments(filter);
1222
+ const attachments = await this.filterReadable(await this.store.getAttachments(filter), (a) => this.projectIdOfAttachment(a));
1046
1223
  if (filter?.artifactType) {
1047
1224
  return attachments.filter(a => a.artifactType === filter.artifactType);
1048
1225
  }
1049
1226
  return attachments;
1050
1227
  }
1051
1228
  async getAttachment(id) {
1052
- return this.store.getAttachment(id);
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);
1053
1241
  }
1054
1242
  async createAttachment(input) {
1243
+ await this.requireAttachmentCreate(input);
1055
1244
  const attachment = this.actor
1056
1245
  ? { ...input, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' }
1057
1246
  : { ...input, uploaderId: input.uploaderId ?? 'system' };
@@ -1071,9 +1260,13 @@ export class CriticalPathEngine {
1071
1260
  return created;
1072
1261
  }
1073
1262
  async deleteAttachment(id) {
1074
- const existing = await this.store.getAttachment(id);
1263
+ const existing = await this.getAttachment(id);
1075
1264
  if (!existing)
1076
1265
  return false;
1266
+ await this.requireProjectAccess('attachment.delete', await this.projectIdOfAttachment(existing), {
1267
+ type: 'attachment',
1268
+ ownerId: existing.uploaderId
1269
+ });
1077
1270
  if (existing.storageKey && this.fileStorage) {
1078
1271
  try {
1079
1272
  await this.fileStorage.delete(existing.storageKey);
@@ -1102,6 +1295,8 @@ export class CriticalPathEngine {
1102
1295
  if (!this.fileStorage) {
1103
1296
  throw new Error('No FileStorageAdapter configured in CriticalPathEngine. Pass "fileStorage" in config to enable direct file uploads.');
1104
1297
  }
1298
+ // Check before uploading so denied requests do not leave orphaned files behind.
1299
+ await this.requireAttachmentCreate(input);
1105
1300
  const uploadResult = await this.fileStorage.upload({
1106
1301
  filename: input.filename,
1107
1302
  data: input.data,
@@ -1155,17 +1350,33 @@ export class CriticalPathEngine {
1155
1350
  if (!this.fileStorage || !this.fileStorage.getPresignedUploadUrl) {
1156
1351
  throw new Error('Presigned uploads are not supported by the configured FileStorageAdapter.');
1157
1352
  }
1158
- 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);
1159
1367
  }
1160
1368
  // --- Teams ---
1161
1369
  async getTeams() {
1162
- return this.store.getTeams();
1370
+ const teams = await this.store.getTeams();
1371
+ return teams.filter((t) => this.inActorTenant(t));
1163
1372
  }
1164
1373
  async getTeam(id) {
1165
- return this.store.getTeam(id);
1374
+ const team = await this.store.getTeam(id);
1375
+ return team && this.inActorTenant(team) ? team : null;
1166
1376
  }
1167
1377
  async createTeam(team) {
1168
- 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 });
1169
1380
  const now = new Date().toISOString();
1170
1381
  const event = {
1171
1382
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -1180,19 +1391,31 @@ export class CriticalPathEngine {
1180
1391
  return created;
1181
1392
  }
1182
1393
  async updateTeam(id, updates) {
1183
- 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));
1184
1398
  }
1185
1399
  async deleteTeam(id) {
1400
+ if (!(await this.getTeam(id)))
1401
+ return false;
1402
+ await this.requireWorkspaceAccess('workspace.manage');
1186
1403
  return this.store.deleteTeam(id);
1187
1404
  }
1188
1405
  // --- Containers ---
1189
1406
  async getContainers(projectId) {
1407
+ if (!(await this.canReadProject(projectId)))
1408
+ return [];
1190
1409
  return this.store.getContainers(projectId);
1191
1410
  }
1192
1411
  async getContainer(id) {
1193
- 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;
1194
1416
  }
1195
1417
  async createContainer(container) {
1418
+ await this.requireProjectAccess('plan.manage', container.projectId);
1196
1419
  const created = await this.store.createContainer(container);
1197
1420
  const now = new Date().toISOString();
1198
1421
  const event = {
@@ -1208,19 +1431,33 @@ export class CriticalPathEngine {
1208
1431
  return created;
1209
1432
  }
1210
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);
1211
1438
  return this.store.updateContainer(id, updates);
1212
1439
  }
1213
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);
1214
1445
  return this.store.deleteContainer(id);
1215
1446
  }
1216
1447
  // --- Deliverables ---
1217
1448
  async getDeliverables(projectId) {
1449
+ if (!(await this.canReadProject(projectId)))
1450
+ return [];
1218
1451
  return this.store.getDeliverables(projectId);
1219
1452
  }
1220
1453
  async getDeliverable(id) {
1221
- 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;
1222
1458
  }
1223
1459
  async createDeliverable(input) {
1460
+ await this.requireProjectAccess('plan.manage', input.projectId);
1224
1461
  const project = await this.store.getProject(input.projectId);
1225
1462
  if (project?.customFieldDefinitions && input.customFields) {
1226
1463
  validateCustomFieldValues(project.customFieldDefinitions, input.customFields);
@@ -1251,9 +1488,10 @@ export class CriticalPathEngine {
1251
1488
  return created;
1252
1489
  }
1253
1490
  async updateDeliverable(id, updates) {
1254
- const existing = await this.store.getDeliverable(id);
1491
+ const existing = await this.getDeliverable(id);
1255
1492
  if (!existing)
1256
1493
  return null;
1494
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1257
1495
  if (updates.status && updates.status === 'delivered' && !existing.deliveredAt && !updates.deliveredAt) {
1258
1496
  updates.deliveredAt = new Date().toISOString();
1259
1497
  }
@@ -1300,9 +1538,10 @@ export class CriticalPathEngine {
1300
1538
  return updated;
1301
1539
  }
1302
1540
  async deleteDeliverable(id) {
1303
- const existing = await this.store.getDeliverable(id);
1541
+ const existing = await this.getDeliverable(id);
1304
1542
  if (!existing)
1305
1543
  return false;
1544
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1306
1545
  const deleted = await this.store.deleteDeliverable(id);
1307
1546
  if (deleted) {
1308
1547
  const now = new Date().toISOString();
@@ -1326,7 +1565,7 @@ export class CriticalPathEngine {
1326
1565
  return deleted;
1327
1566
  }
1328
1567
  async getDeliverableSummary(deliverableId) {
1329
- const deliverable = await this.store.getDeliverable(deliverableId);
1568
+ const deliverable = await this.getDeliverable(deliverableId);
1330
1569
  if (!deliverable)
1331
1570
  return null;
1332
1571
  const project = await this.store.getProject(deliverable.projectId);
@@ -1371,12 +1610,18 @@ export class CriticalPathEngine {
1371
1610
  }
1372
1611
  // --- Iterations ---
1373
1612
  async getIterations(projectId) {
1613
+ if (!(await this.canReadProject(projectId)))
1614
+ return [];
1374
1615
  return this.store.getIterations(projectId);
1375
1616
  }
1376
1617
  async getIteration(id) {
1377
- 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;
1378
1622
  }
1379
1623
  async createIteration(iteration) {
1624
+ await this.requireProjectAccess('plan.manage', iteration.projectId);
1380
1625
  const created = await this.store.createIteration(iteration);
1381
1626
  if (created.status === 'active') {
1382
1627
  const now = new Date().toISOString();
@@ -1394,9 +1639,10 @@ export class CriticalPathEngine {
1394
1639
  return created;
1395
1640
  }
1396
1641
  async updateIteration(id, updates) {
1397
- const existing = await this.store.getIteration(id);
1642
+ const existing = await this.getIteration(id);
1398
1643
  if (!existing)
1399
1644
  return null;
1645
+ await this.requireProjectAccess('plan.manage', existing.projectId);
1400
1646
  const updated = await this.store.updateIteration(id, updates);
1401
1647
  if (updated) {
1402
1648
  const now = new Date().toISOString();
@@ -1428,10 +1674,15 @@ export class CriticalPathEngine {
1428
1674
  return updated;
1429
1675
  }
1430
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);
1431
1681
  return this.store.deleteIteration(id);
1432
1682
  }
1433
1683
  // --- Ladder of Abstraction & Critical Path Method ---
1434
1684
  async calculateCriticalPath(projectId, options = {}) {
1685
+ await this.requireProjectAccess('project.read', projectId);
1435
1686
  const project = await this.store.getProject(projectId);
1436
1687
  const tasks = await this.store.getTasks(projectId);
1437
1688
  const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
@@ -1450,6 +1701,7 @@ export class CriticalPathEngine {
1450
1701
  return calculateCPM(projectId, tasks, dependencies, { schedule, projectStartDate });
1451
1702
  }
1452
1703
  async getTimelineLadder(projectId, options = {}) {
1704
+ await this.requireProjectAccess('project.read', projectId);
1453
1705
  const project = await this.store.getProject(projectId);
1454
1706
  if (!project) {
1455
1707
  throw new NotFoundError(`Project with ID "${projectId}" not found.`);
@@ -1496,7 +1748,7 @@ export class CriticalPathEngine {
1496
1748
  }, options);
1497
1749
  }
1498
1750
  async getTaskLadder(taskId) {
1499
- const task = await this.store.getTask(taskId);
1751
+ const task = await this.getTask(taskId);
1500
1752
  if (!task)
1501
1753
  return null;
1502
1754
  const project = await this.store.getProject(task.projectId);
@@ -1518,7 +1770,7 @@ export class CriticalPathEngine {
1518
1770
  };
1519
1771
  }
1520
1772
  async getTaskMetrics(taskId, options) {
1521
- const task = await this.store.getTask(taskId);
1773
+ const task = await this.getTask(taskId);
1522
1774
  if (!task)
1523
1775
  return null;
1524
1776
  const [activities, timeEntries, project, workflow] = await Promise.all([
@@ -1534,7 +1786,7 @@ export class CriticalPathEngine {
1534
1786
  });
1535
1787
  }
1536
1788
  async getTaskProgressHistory(taskId, options) {
1537
- const task = await this.store.getTask(taskId);
1789
+ const task = await this.getTask(taskId);
1538
1790
  if (!task)
1539
1791
  return null;
1540
1792
  const [activities, project, workflow] = await Promise.all([
@@ -1549,8 +1801,10 @@ export class CriticalPathEngine {
1549
1801
  });
1550
1802
  }
1551
1803
  async getWorkloadDistribution(projectId, options = {}) {
1552
- const tasks = await this.store.getTasks(projectId);
1553
- 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();
1554
1808
  const project = projectId ? await this.store.getProject(projectId) : undefined;
1555
1809
  const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
1556
1810
  const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));