@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.
- package/CHANGELOG.md +24 -0
- package/README.md +12 -0
- package/dist/domain/authorization.d.ts +23 -0
- package/dist/domain/authorization.d.ts.map +1 -0
- package/dist/domain/authorization.js +62 -0
- package/dist/domain/authorization.js.map +1 -0
- package/dist/domain/entities.d.ts +2 -2
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js.map +1 -1
- package/dist/domain/errors.d.ts +6 -0
- package/dist/domain/errors.d.ts.map +1 -1
- package/dist/domain/errors.js +9 -0
- package/dist/domain/errors.js.map +1 -1
- package/dist/domain/index.d.ts +1 -0
- package/dist/domain/index.d.ts.map +1 -1
- package/dist/domain/index.js +1 -0
- package/dist/domain/index.js.map +1 -1
- package/dist/engine/authorization.test.d.ts +2 -0
- package/dist/engine/authorization.test.d.ts.map +1 -0
- package/dist/engine/authorization.test.js +150 -0
- package/dist/engine/authorization.test.js.map +1 -0
- package/dist/engine/index.d.ts +37 -2
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +302 -48
- package/dist/engine/index.js.map +1 -1
- package/dist/schemas/index.d.ts +40 -6
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +5 -1
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/schemas.test.js.map +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +20 -9
- package/dist/store/sqlite.js.map +1 -1
- package/dist/types/index.d.ts +45 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/domain/authorization.ts +82 -0
- package/src/domain/entities.ts +2 -1
- package/src/domain/errors.ts +10 -0
- package/src/domain/index.ts +1 -0
- package/src/engine/authorization.test.ts +191 -0
- package/src/engine/index.ts +295 -49
- package/src/schemas/index.ts +5 -1
- package/src/schemas/schemas.test.ts +3 -2
- package/src/store/sqlite.ts +20 -6
- package/src/types/index.ts +66 -1
- package/tsconfig.tsbuildinfo +1 -1
package/dist/engine/index.js
CHANGED
|
@@ -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
|
-
|
|
193
|
+
const workflows = await this.store.getWorkflows();
|
|
194
|
+
return workflows.filter((w) => this.inActorTenant(w));
|
|
96
195
|
}
|
|
97
196
|
async getWorkflow(id) {
|
|
98
|
-
|
|
197
|
+
const workflow = await this.store.getWorkflow(id);
|
|
198
|
+
return workflow && this.inActorTenant(workflow) ? workflow : null;
|
|
99
199
|
}
|
|
100
200
|
async createWorkflow(workflow) {
|
|
101
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
315
|
+
const projects = await this.store.getProjects();
|
|
316
|
+
return this.filterReadable(projects, (p) => p.id);
|
|
211
317
|
}
|
|
212
318
|
async getProject(id) {
|
|
213
|
-
|
|
319
|
+
if (!this.enforcing)
|
|
320
|
+
return this.store.getProject(id);
|
|
321
|
+
return this.readableProject(id);
|
|
214
322
|
}
|
|
215
323
|
async createProject(project) {
|
|
216
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
845
|
-
const downstreamTasks = (await Promise.all(Array.from(downstreamTaskIds).map((id) => this.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1370
|
+
const teams = await this.store.getTeams();
|
|
1371
|
+
return teams.filter((t) => this.inActorTenant(t));
|
|
1163
1372
|
}
|
|
1164
1373
|
async getTeam(id) {
|
|
1165
|
-
|
|
1374
|
+
const team = await this.store.getTeam(id);
|
|
1375
|
+
return team && this.inActorTenant(team) ? team : null;
|
|
1166
1376
|
}
|
|
1167
1377
|
async createTeam(team) {
|
|
1168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1553
|
-
|
|
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)));
|