@critical-path/server 0.14.0 → 0.16.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/dist/router.js CHANGED
@@ -1,9 +1,8 @@
1
1
  import { CriticalPathEngine } from '@critical-path/core';
2
- const CORS_HEADERS = {
3
- 'Access-Control-Allow-Origin': '*',
4
- 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
5
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization'
6
- };
2
+ import * as schemas from '@critical-path/core/schemas';
3
+ import { buildOpenApiDocument } from './openapi.js';
4
+ const CORS_ALLOW_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
5
+ const DEFAULT_CORS_ALLOW_HEADERS = ['Content-Type', 'Authorization'];
7
6
  class BadRequestError extends Error {
8
7
  constructor(message) {
9
8
  super(message);
@@ -17,6 +16,10 @@ export class CriticalPathRouter {
17
16
  engine;
18
17
  onError;
19
18
  exposeErrors;
19
+ getContext;
20
+ requireAuth;
21
+ basePath;
22
+ cors;
20
23
  constructor(configOrEngine, options = {}) {
21
24
  if (configOrEngine instanceof CriticalPathEngine) {
22
25
  this.engine = configOrEngine;
@@ -26,49 +29,122 @@ export class CriticalPathRouter {
26
29
  }
27
30
  this.onError = options.onError;
28
31
  this.exposeErrors = options.exposeErrors ?? isDevelopment();
32
+ this.getContext = options.getContext;
33
+ this.requireAuth = options.requireAuth ?? false;
34
+ this.basePath = options.basePath ? '/' + options.basePath.replace(/^\/+|\/+$/g, '') : undefined;
35
+ this.cors = options.cors === undefined ? { origins: '*' } : options.cors;
36
+ if (this.cors && this.cors.origins === '*' && this.cors.credentials) {
37
+ throw new Error('CORS credentials cannot be combined with origins "*"; list the allowed origins instead.');
38
+ }
39
+ }
40
+ /**
41
+ * Handles a request. Adapters that resolve identity from something other than the `Request`
42
+ * (e.g. SvelteKit `locals`) pass `init.getContext`, which replaces the router's `getContext`
43
+ * for this request.
44
+ */
45
+ async handleRequest(request, init = {}) {
46
+ const response = await this.dispatch(request, init);
47
+ return this.applyCors(request, response);
48
+ }
49
+ /** Returns the path after the mount point, or `null` when the request is outside `basePath`. */
50
+ resolveSubpath(pathname) {
51
+ if (this.basePath) {
52
+ if (pathname !== this.basePath && !pathname.startsWith(this.basePath + '/'))
53
+ return null;
54
+ return pathname.slice(this.basePath.length).replace(/^\/+/, '');
55
+ }
56
+ // Extract subpath after /critical-path/ or /api/critical-path/
57
+ return pathname.replace(/^.*?\/critical-path\/?/, '').replace(/^\/+/, '');
58
+ }
59
+ applyCors(request, response) {
60
+ if (!this.cors)
61
+ return response;
62
+ const origin = request.headers.get('Origin');
63
+ let allowOrigin;
64
+ if (this.cors.origins === '*') {
65
+ allowOrigin = '*';
66
+ }
67
+ else if (origin && this.cors.origins.includes(origin)) {
68
+ allowOrigin = origin;
69
+ }
70
+ // Copy so headers are mutable even if a hook returned a Response with immutable headers.
71
+ const result = new Response(response.body, response);
72
+ if (this.cors.origins !== '*')
73
+ result.headers.append('Vary', 'Origin');
74
+ if (!allowOrigin)
75
+ return result;
76
+ result.headers.set('Access-Control-Allow-Origin', allowOrigin);
77
+ result.headers.set('Access-Control-Allow-Methods', CORS_ALLOW_METHODS);
78
+ result.headers.set('Access-Control-Allow-Headers', (this.cors.allowHeaders ?? DEFAULT_CORS_ALLOW_HEADERS).join(', '));
79
+ if (this.cors.credentials)
80
+ result.headers.set('Access-Control-Allow-Credentials', 'true');
81
+ if (request.method.toUpperCase() === 'OPTIONS' && this.cors.maxAge !== undefined) {
82
+ result.headers.set('Access-Control-Max-Age', String(this.cors.maxAge));
83
+ }
84
+ return result;
29
85
  }
30
- async handleRequest(request) {
86
+ async dispatch(request, init) {
31
87
  const url = new URL(request.url);
32
88
  const pathname = url.pathname;
33
89
  const method = request.method.toUpperCase();
34
- // Extract subpath after /critical-path/ or /api/critical-path/
35
- const subpath = pathname.replace(/^.*?\/critical-path\/?/, '').replace(/^\/+/, '');
90
+ const subpath = this.resolveSubpath(pathname);
91
+ if (subpath === null) {
92
+ return this.jsonResponse({ error: `Route not found: ${method} ${pathname}` }, 404);
93
+ }
36
94
  const segments = subpath.split('/').filter(Boolean);
37
- // CORS preflight
95
+ // CORS preflight (browsers send it without credentials, so it is answered before auth)
38
96
  if (method === 'OPTIONS') {
39
- return new Response(null, { status: 204, headers: CORS_HEADERS });
97
+ return new Response(null, { status: 204 });
98
+ }
99
+ let context;
100
+ try {
101
+ const resolve = init.getContext ?? this.getContext;
102
+ context = resolve ? await resolve(request) : undefined;
40
103
  }
104
+ catch (err) {
105
+ return this.errorResponse(err, request);
106
+ }
107
+ if (this.requireAuth && !context?.userId) {
108
+ return this.jsonResponse({ error: 'Authentication required' }, 401);
109
+ }
110
+ const engine = context?.userId
111
+ ? this.engine.withActor({ userId: context.userId, username: context.userName, actorType: context.actorType })
112
+ : this.engine;
41
113
  try {
114
+ // OpenAPI contract (behind the same auth as every other route)
115
+ if (segments[0] === 'openapi.json' && segments.length === 1 && method === 'GET') {
116
+ return this.jsonResponse(buildOpenApiDocument({ serverUrl: this.openApiServerUrl(url, subpath) }));
117
+ }
42
118
  // Workflows API
43
119
  if (segments[0] === 'workflows') {
44
120
  const workflowId = segments[1];
45
121
  if (!workflowId) {
46
122
  if (method === 'GET') {
47
- const workflows = await this.engine.getWorkflows();
123
+ const workflows = await engine.getWorkflows();
48
124
  return this.jsonResponse({ workflows });
49
125
  }
50
126
  if (method === 'POST') {
51
- const body = await this.readJson(request);
52
- const workflow = await this.engine.createWorkflow(body);
127
+ const body = await this.readBody(request, schemas.CreateWorkflowSchema);
128
+ const workflow = await engine.createWorkflow(body);
53
129
  return this.jsonResponse({ workflow }, 201);
54
130
  }
55
131
  }
56
132
  else {
57
133
  if (method === 'GET') {
58
- const workflow = await this.engine.getWorkflow(workflowId);
134
+ const workflow = await engine.getWorkflow(workflowId);
59
135
  if (!workflow)
60
136
  return this.jsonResponse({ error: 'Workflow not found' }, 404);
61
137
  return this.jsonResponse({ workflow });
62
138
  }
63
139
  if (method === 'PATCH' || method === 'PUT') {
64
- const body = await this.readJson(request);
65
- const updated = await this.engine.updateWorkflow(workflowId, body);
140
+ const body = await this.readBody(request, schemas.UpdateWorkflowSchema);
141
+ const updated = await engine.updateWorkflow(workflowId, body);
66
142
  if (!updated)
67
143
  return this.jsonResponse({ error: 'Workflow not found' }, 404);
68
144
  return this.jsonResponse({ workflow: updated });
69
145
  }
70
146
  if (method === 'DELETE') {
71
- const deleted = await this.engine.deleteWorkflow(workflowId);
147
+ const deleted = await engine.deleteWorkflow(workflowId);
72
148
  if (!deleted)
73
149
  return this.jsonResponse({ error: 'Not found' }, 404);
74
150
  return this.jsonResponse({ success: true });
@@ -81,18 +157,18 @@ export class CriticalPathRouter {
81
157
  const subResource = segments[2];
82
158
  if (!projectId) {
83
159
  if (method === 'GET') {
84
- const projects = await this.engine.getProjects();
160
+ const projects = await engine.getProjects();
85
161
  return this.jsonResponse({ projects });
86
162
  }
87
163
  if (method === 'POST') {
88
- const body = await this.readJson(request);
89
- const project = await this.engine.createProject(body);
164
+ const body = await this.readBody(request, schemas.CreateProjectSchema);
165
+ const project = await engine.createProject(body);
90
166
  return this.jsonResponse({ project }, 201);
91
167
  }
92
168
  }
93
169
  else if (subResource === 'critical-path') {
94
170
  if (method === 'GET') {
95
- const analysis = await this.engine.calculateCriticalPath(projectId);
171
+ const analysis = await engine.calculateCriticalPath(projectId);
96
172
  return this.jsonResponse({ analysis });
97
173
  }
98
174
  }
@@ -101,7 +177,7 @@ export class CriticalPathRouter {
101
177
  const level = (url.searchParams.get('level') || 'all');
102
178
  const containerId = url.searchParams.get('containerId') || undefined;
103
179
  const iterationId = url.searchParams.get('iterationId') || undefined;
104
- const ladder = await this.engine.getTimelineLadder(projectId, { level, containerId, iterationId });
180
+ const ladder = await engine.getTimelineLadder(projectId, { level, containerId, iterationId });
105
181
  return this.jsonResponse({ ladder });
106
182
  }
107
183
  }
@@ -115,7 +191,7 @@ export class CriticalPathRouter {
115
191
  const defaultWeeklyCapacityHours = url.searchParams.get('defaultWeeklyCapacityHours')
116
192
  ? parseFloat(url.searchParams.get('defaultWeeklyCapacityHours'))
117
193
  : undefined;
118
- const workload = await this.engine.getWorkloadDistribution(projectId, {
194
+ const workload = await engine.getWorkloadDistribution(projectId, {
119
195
  startDate,
120
196
  endDate,
121
197
  interval,
@@ -128,20 +204,20 @@ export class CriticalPathRouter {
128
204
  }
129
205
  else {
130
206
  if (method === 'GET') {
131
- const project = await this.engine.getProject(projectId);
207
+ const project = await engine.getProject(projectId);
132
208
  if (!project)
133
209
  return this.jsonResponse({ error: 'Project not found' }, 404);
134
210
  return this.jsonResponse({ project });
135
211
  }
136
212
  if (method === 'PATCH' || method === 'PUT') {
137
- const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...updates } = await this.readJson(request);
138
- const project = await this.engine.updateProject(projectId, updates);
213
+ const updates = await this.readBody(request, schemas.UpdateProjectSchema);
214
+ const project = await engine.updateProject(projectId, updates);
139
215
  if (!project)
140
216
  return this.jsonResponse({ error: 'Project not found' }, 404);
141
217
  return this.jsonResponse({ project });
142
218
  }
143
219
  if (method === 'DELETE') {
144
- const deleted = await this.engine.deleteProject(projectId);
220
+ const deleted = await engine.deleteProject(projectId);
145
221
  if (!deleted)
146
222
  return this.jsonResponse({ error: 'Not found' }, 404);
147
223
  return this.jsonResponse({ success: true });
@@ -155,48 +231,45 @@ export class CriticalPathRouter {
155
231
  if (!taskId) {
156
232
  if (method === 'GET') {
157
233
  const projectId = url.searchParams.get('projectId') || undefined;
158
- const tasks = await this.engine.getTasks(projectId);
234
+ const tasks = await engine.getTasks(projectId);
159
235
  return this.jsonResponse({ tasks });
160
236
  }
161
237
  if (method === 'POST') {
162
- const body = await this.readJson(request);
163
- const task = await this.engine.createTask(body);
238
+ const body = await this.readBody(request, schemas.CreateTaskSchema);
239
+ const task = await engine.createTask(body);
164
240
  return this.jsonResponse({ task }, 201);
165
241
  }
166
242
  }
167
243
  else if (subResource === 'comments') {
168
244
  if (method === 'GET') {
169
- const comments = await this.engine.getComments(taskId);
245
+ const comments = await engine.getComments(taskId);
170
246
  return this.jsonResponse({ comments });
171
247
  }
172
248
  if (method === 'POST') {
173
- const body = await this.readJson(request);
174
- const comment = await this.engine.addComment({ ...body, taskId });
249
+ const body = await this.readBody(request, schemas.CreateCommentSchema, { taskId });
250
+ const comment = await engine.addComment({ ...body, authorId: this.requireIdentity(context, body.authorId, 'authorId') });
175
251
  return this.jsonResponse({ comment }, 201);
176
252
  }
177
253
  }
178
254
  else if (subResource === 'attachments') {
179
255
  if (method === 'GET') {
180
- const attachments = await this.engine.getAttachments({ taskId });
256
+ const attachments = await engine.getAttachments({ taskId });
181
257
  return this.jsonResponse({ attachments });
182
258
  }
183
259
  if (method === 'POST') {
184
- const body = await this.readJson(request);
185
- const attachment = await this.engine.createAttachment({ ...body, taskId });
260
+ const body = await this.readBody(request, schemas.CreateAttachmentSchema, { taskId });
261
+ const attachment = await engine.createAttachment({ ...body, uploaderId: this.requireIdentity(context, body.uploaderId, 'uploaderId') });
186
262
  return this.jsonResponse({ attachment }, 201);
187
263
  }
188
264
  }
189
265
  else if (subResource === 'dependencies') {
190
266
  if (method === 'GET') {
191
- const graph = await this.engine.getTaskDependencyGraph(taskId);
267
+ const graph = await engine.getTaskDependencyGraph(taskId);
192
268
  return this.jsonResponse({ graph });
193
269
  }
194
270
  if (method === 'POST') {
195
- const body = await this.readJson(request);
196
- if (typeof body.dependsOnTaskId !== 'string' || !body.dependsOnTaskId) {
197
- return this.jsonResponse({ error: 'dependsOnTaskId is required' }, 400);
198
- }
199
- const dep = await this.engine.addDependency({
271
+ const body = await this.readBody(request, schemas.CreateDependencySchema);
272
+ const dep = await engine.addDependency({
200
273
  taskId,
201
274
  dependsOnTaskId: body.dependsOnTaskId,
202
275
  type: body.type || 'blocking'
@@ -206,7 +279,7 @@ export class CriticalPathRouter {
206
279
  }
207
280
  else if (subResource === 'state' || subResource === 'lifecycle') {
208
281
  if (method === 'GET') {
209
- const state = await this.engine.getTaskLifecycleState(taskId);
282
+ const state = await engine.getTaskLifecycleState(taskId);
210
283
  if (!state)
211
284
  return this.jsonResponse({ error: 'Task not found' }, 404);
212
285
  return this.jsonResponse({ state });
@@ -214,14 +287,14 @@ export class CriticalPathRouter {
214
287
  }
215
288
  else if (subResource === 'transitions' || subResource === 'allowed-transitions') {
216
289
  if (method === 'GET') {
217
- const allowedNextStatuses = await this.engine.getAllowedTaskTransitions(taskId);
218
- const allowedPreviousStatuses = await this.engine.getAllowedPreviousTaskTransitions(taskId);
290
+ const allowedNextStatuses = await engine.getAllowedTaskTransitions(taskId);
291
+ const allowedPreviousStatuses = await engine.getAllowedPreviousTaskTransitions(taskId);
219
292
  return this.jsonResponse({ allowedNextStatuses, allowedPreviousStatuses });
220
293
  }
221
294
  }
222
295
  else if (subResource === 'ladder') {
223
296
  if (method === 'GET') {
224
- const taskLadder = await this.engine.getTaskLadder(taskId);
297
+ const taskLadder = await engine.getTaskLadder(taskId);
225
298
  if (!taskLadder)
226
299
  return this.jsonResponse({ error: 'Task not found' }, 404);
227
300
  return this.jsonResponse({ taskLadder });
@@ -229,7 +302,7 @@ export class CriticalPathRouter {
229
302
  }
230
303
  else if (subResource === 'metrics') {
231
304
  if (method === 'GET') {
232
- const metrics = await this.engine.getTaskMetrics(taskId);
305
+ const metrics = await engine.getTaskMetrics(taskId);
233
306
  if (!metrics)
234
307
  return this.jsonResponse({ error: 'Task not found' }, 404);
235
308
  return this.jsonResponse({ metrics });
@@ -237,7 +310,7 @@ export class CriticalPathRouter {
237
310
  }
238
311
  else if (subResource === 'progress-history') {
239
312
  if (method === 'GET') {
240
- const progressHistory = await this.engine.getTaskProgressHistory(taskId);
313
+ const progressHistory = await engine.getTaskProgressHistory(taskId);
241
314
  if (!progressHistory)
242
315
  return this.jsonResponse({ error: 'Task not found' }, 404);
243
316
  return this.jsonResponse({ progressHistory });
@@ -245,20 +318,20 @@ export class CriticalPathRouter {
245
318
  }
246
319
  else {
247
320
  if (method === 'GET') {
248
- const task = await this.engine.getTask(taskId);
321
+ const task = await engine.getTask(taskId);
249
322
  if (!task)
250
323
  return this.jsonResponse({ error: 'Task not found' }, 404);
251
324
  return this.jsonResponse({ task });
252
325
  }
253
326
  if (method === 'PATCH' || method === 'PUT') {
254
- const body = await this.readJson(request);
255
- const updated = await this.engine.updateTask(taskId, body);
327
+ const body = await this.readBody(request, schemas.UpdateTaskSchema);
328
+ const updated = await engine.updateTask(taskId, body);
256
329
  if (!updated)
257
330
  return this.jsonResponse({ error: 'Task not found' }, 404);
258
331
  return this.jsonResponse({ task: updated });
259
332
  }
260
333
  if (method === 'DELETE') {
261
- const deleted = await this.engine.deleteTask(taskId);
334
+ const deleted = await engine.deleteTask(taskId);
262
335
  if (!deleted)
263
336
  return this.jsonResponse({ error: 'Not found' }, 404);
264
337
  return this.jsonResponse({ success: true });
@@ -270,31 +343,31 @@ export class CriticalPathRouter {
270
343
  const teamId = segments[1];
271
344
  if (!teamId) {
272
345
  if (method === 'GET') {
273
- const teams = await this.engine.getTeams();
346
+ const teams = await engine.getTeams();
274
347
  return this.jsonResponse({ teams });
275
348
  }
276
349
  if (method === 'POST') {
277
- const body = await this.readJson(request);
278
- const team = await this.engine.createTeam(body);
350
+ const body = await this.readBody(request, schemas.CreateTeamSchema);
351
+ const team = await engine.createTeam(body);
279
352
  return this.jsonResponse({ team }, 201);
280
353
  }
281
354
  }
282
355
  else {
283
356
  if (method === 'GET') {
284
- const team = await this.engine.getTeam(teamId);
357
+ const team = await engine.getTeam(teamId);
285
358
  if (!team)
286
359
  return this.jsonResponse({ error: 'Team not found' }, 404);
287
360
  return this.jsonResponse({ team });
288
361
  }
289
362
  if (method === 'PATCH' || method === 'PUT') {
290
- const body = await this.readJson(request);
291
- const updated = await this.engine.updateTeam(teamId, body);
363
+ const body = await this.readBody(request, schemas.UpdateTeamSchema);
364
+ const updated = await engine.updateTeam(teamId, body);
292
365
  if (!updated)
293
366
  return this.jsonResponse({ error: 'Team not found' }, 404);
294
367
  return this.jsonResponse({ team: updated });
295
368
  }
296
369
  if (method === 'DELETE') {
297
- const deleted = await this.engine.deleteTeam(teamId);
370
+ const deleted = await engine.deleteTeam(teamId);
298
371
  if (!deleted)
299
372
  return this.jsonResponse({ error: 'Not found' }, 404);
300
373
  return this.jsonResponse({ success: true });
@@ -309,31 +382,31 @@ export class CriticalPathRouter {
309
382
  const projectId = url.searchParams.get('projectId');
310
383
  if (!projectId)
311
384
  return this.jsonResponse({ error: 'projectId parameter required' }, 400);
312
- const containers = await this.engine.getContainers(projectId);
385
+ const containers = await engine.getContainers(projectId);
313
386
  return this.jsonResponse({ containers });
314
387
  }
315
388
  if (method === 'POST') {
316
- const body = await this.readJson(request);
317
- const container = await this.engine.createContainer(body);
389
+ const body = await this.readBody(request, schemas.CreateContainerSchema);
390
+ const container = await engine.createContainer(body);
318
391
  return this.jsonResponse({ container }, 201);
319
392
  }
320
393
  }
321
394
  else {
322
395
  if (method === 'GET') {
323
- const container = await this.engine.getContainer(containerId);
396
+ const container = await engine.getContainer(containerId);
324
397
  if (!container)
325
398
  return this.jsonResponse({ error: 'Container not found' }, 404);
326
399
  return this.jsonResponse({ container });
327
400
  }
328
401
  if (method === 'PATCH' || method === 'PUT') {
329
- const body = await this.readJson(request);
330
- const updated = await this.engine.updateContainer(containerId, body);
402
+ const body = await this.readBody(request, schemas.UpdateContainerSchema);
403
+ const updated = await engine.updateContainer(containerId, body);
331
404
  if (!updated)
332
405
  return this.jsonResponse({ error: 'Container not found' }, 404);
333
406
  return this.jsonResponse({ container: updated });
334
407
  }
335
408
  if (method === 'DELETE') {
336
- const deleted = await this.engine.deleteContainer(containerId);
409
+ const deleted = await engine.deleteContainer(containerId);
337
410
  if (!deleted)
338
411
  return this.jsonResponse({ error: 'Not found' }, 404);
339
412
  return this.jsonResponse({ success: true });
@@ -348,18 +421,18 @@ export class CriticalPathRouter {
348
421
  const projectId = url.searchParams.get('projectId');
349
422
  if (!projectId)
350
423
  return this.jsonResponse({ error: 'projectId parameter required' }, 400);
351
- const deliverables = await this.engine.getDeliverables(projectId);
424
+ const deliverables = await engine.getDeliverables(projectId);
352
425
  return this.jsonResponse({ deliverables });
353
426
  }
354
427
  if (method === 'POST') {
355
- const body = await this.readJson(request);
356
- const deliverable = await this.engine.createDeliverable(body);
428
+ const body = await this.readBody(request, schemas.CreateDeliverableSchema);
429
+ const deliverable = await engine.createDeliverable(body);
357
430
  return this.jsonResponse({ deliverable }, 201);
358
431
  }
359
432
  }
360
433
  else if (segments[2] === 'summary') {
361
434
  if (method === 'GET') {
362
- const summary = await this.engine.getDeliverableSummary(deliverableId);
435
+ const summary = await engine.getDeliverableSummary(deliverableId);
363
436
  if (!summary)
364
437
  return this.jsonResponse({ error: 'Deliverable not found' }, 404);
365
438
  return this.jsonResponse({ summary });
@@ -367,20 +440,20 @@ export class CriticalPathRouter {
367
440
  }
368
441
  else {
369
442
  if (method === 'GET') {
370
- const deliverable = await this.engine.getDeliverable(deliverableId);
443
+ const deliverable = await engine.getDeliverable(deliverableId);
371
444
  if (!deliverable)
372
445
  return this.jsonResponse({ error: 'Deliverable not found' }, 404);
373
446
  return this.jsonResponse({ deliverable });
374
447
  }
375
448
  if (method === 'PATCH' || method === 'PUT') {
376
- const body = await this.readJson(request);
377
- const updated = await this.engine.updateDeliverable(deliverableId, body);
449
+ const body = await this.readBody(request, schemas.UpdateDeliverableSchema);
450
+ const updated = await engine.updateDeliverable(deliverableId, body);
378
451
  if (!updated)
379
452
  return this.jsonResponse({ error: 'Deliverable not found' }, 404);
380
453
  return this.jsonResponse({ deliverable: updated });
381
454
  }
382
455
  if (method === 'DELETE') {
383
- const deleted = await this.engine.deleteDeliverable(deliverableId);
456
+ const deleted = await engine.deleteDeliverable(deliverableId);
384
457
  if (!deleted)
385
458
  return this.jsonResponse({ error: 'Not found' }, 404);
386
459
  return this.jsonResponse({ success: true });
@@ -395,31 +468,31 @@ export class CriticalPathRouter {
395
468
  const projectId = url.searchParams.get('projectId');
396
469
  if (!projectId)
397
470
  return this.jsonResponse({ error: 'projectId parameter required' }, 400);
398
- const iterations = await this.engine.getIterations(projectId);
471
+ const iterations = await engine.getIterations(projectId);
399
472
  return this.jsonResponse({ iterations });
400
473
  }
401
474
  if (method === 'POST') {
402
- const body = await this.readJson(request);
403
- const iteration = await this.engine.createIteration(body);
475
+ const body = await this.readBody(request, schemas.CreateIterationSchema);
476
+ const iteration = await engine.createIteration(body);
404
477
  return this.jsonResponse({ iteration }, 201);
405
478
  }
406
479
  }
407
480
  else {
408
481
  if (method === 'GET') {
409
- const iteration = await this.engine.getIteration(iterationId);
482
+ const iteration = await engine.getIteration(iterationId);
410
483
  if (!iteration)
411
484
  return this.jsonResponse({ error: 'Iteration not found' }, 404);
412
485
  return this.jsonResponse({ iteration });
413
486
  }
414
487
  if (method === 'PATCH' || method === 'PUT') {
415
- const body = await this.readJson(request);
416
- const updated = await this.engine.updateIteration(iterationId, body);
488
+ const body = await this.readBody(request, schemas.UpdateIterationSchema);
489
+ const updated = await engine.updateIteration(iterationId, body);
417
490
  if (!updated)
418
491
  return this.jsonResponse({ error: 'Iteration not found' }, 404);
419
492
  return this.jsonResponse({ iteration: updated });
420
493
  }
421
494
  if (method === 'DELETE') {
422
- const deleted = await this.engine.deleteIteration(iterationId);
495
+ const deleted = await engine.deleteIteration(iterationId);
423
496
  if (!deleted)
424
497
  return this.jsonResponse({ error: 'Not found' }, 404);
425
498
  return this.jsonResponse({ success: true });
@@ -431,7 +504,7 @@ export class CriticalPathRouter {
431
504
  if (method === 'GET') {
432
505
  const projectId = url.searchParams.get('projectId') || undefined;
433
506
  const taskId = url.searchParams.get('taskId') || undefined;
434
- const activities = await this.engine.store.getActivities({ projectId, taskId });
507
+ const activities = await engine.store.getActivities({ projectId, taskId });
435
508
  return this.jsonResponse({ activities });
436
509
  }
437
510
  }
@@ -441,11 +514,9 @@ export class CriticalPathRouter {
441
514
  const subResource = segments[2];
442
515
  if (commentId && subResource === 'reactions') {
443
516
  if (method === 'POST') {
444
- const body = await this.readJson(request);
445
- if (!body.emoji || !body.userId) {
446
- return this.jsonResponse({ error: 'emoji and userId are required' }, 400);
447
- }
448
- const comment = await this.engine.addCommentReaction(commentId, body);
517
+ const body = await this.readBody(request, schemas.CommentReactionSchema);
518
+ const userId = this.requireIdentity(context, body.userId, 'userId');
519
+ const comment = await engine.addCommentReaction(commentId, { emoji: body.emoji, userId });
449
520
  if (!comment)
450
521
  return this.jsonResponse({ error: 'Comment not found' }, 404);
451
522
  return this.jsonResponse({ comment }, 200);
@@ -459,11 +530,11 @@ export class CriticalPathRouter {
459
530
  // Body may be empty on DELETE, fallback to searchParams
460
531
  }
461
532
  const emoji = body.emoji || url.searchParams.get('emoji');
462
- const userId = body.userId || url.searchParams.get('userId');
533
+ const userId = context?.userId ?? (body.userId || url.searchParams.get('userId'));
463
534
  if (!emoji || !userId) {
464
535
  return this.jsonResponse({ error: 'emoji and userId are required' }, 400);
465
536
  }
466
- const comment = await this.engine.removeCommentReaction(commentId, { emoji, userId });
537
+ const comment = await engine.removeCommentReaction(commentId, { emoji, userId });
467
538
  if (!comment)
468
539
  return this.jsonResponse({ error: 'Comment not found' }, 404);
469
540
  return this.jsonResponse({ comment }, 200);
@@ -474,31 +545,31 @@ export class CriticalPathRouter {
474
545
  const taskId = url.searchParams.get('taskId');
475
546
  if (!taskId)
476
547
  return this.jsonResponse({ error: 'taskId parameter required' }, 400);
477
- const comments = await this.engine.getComments(taskId);
548
+ const comments = await engine.getComments(taskId);
478
549
  return this.jsonResponse({ comments });
479
550
  }
480
551
  if (method === 'POST') {
481
- const body = await this.readJson(request);
482
- const comment = await this.engine.addComment(body);
552
+ const body = await this.readBody(request, schemas.CreateCommentSchema);
553
+ const comment = await engine.addComment({ ...body, authorId: this.requireIdentity(context, body.authorId, 'authorId') });
483
554
  return this.jsonResponse({ comment }, 201);
484
555
  }
485
556
  }
486
557
  else {
487
558
  if (method === 'GET') {
488
- const comment = await this.engine.getComment(commentId);
559
+ const comment = await engine.getComment(commentId);
489
560
  if (!comment)
490
561
  return this.jsonResponse({ error: 'Comment not found' }, 404);
491
562
  return this.jsonResponse({ comment });
492
563
  }
493
564
  if (method === 'PATCH' || method === 'PUT') {
494
- const body = await this.readJson(request);
495
- const updated = await this.engine.updateComment(commentId, body);
565
+ const body = await this.readBody(request, schemas.UpdateCommentSchema);
566
+ const updated = await engine.updateComment(commentId, body);
496
567
  if (!updated)
497
568
  return this.jsonResponse({ error: 'Comment not found' }, 404);
498
569
  return this.jsonResponse({ comment: updated });
499
570
  }
500
571
  if (method === 'DELETE') {
501
- const deleted = await this.engine.deleteComment(commentId);
572
+ const deleted = await engine.deleteComment(commentId);
502
573
  if (!deleted)
503
574
  return this.jsonResponse({ error: 'Not found' }, 404);
504
575
  return this.jsonResponse({ success: true });
@@ -510,15 +581,15 @@ export class CriticalPathRouter {
510
581
  const attachmentId = segments[1];
511
582
  if (attachmentId === 'presign') {
512
583
  if (method === 'POST') {
513
- const body = await this.readJson(request);
514
- const presigned = await this.engine.getPresignedAttachmentUploadUrl(body);
584
+ const body = await this.readBody(request, schemas.PresignAttachmentSchema);
585
+ const presigned = await engine.getPresignedAttachmentUploadUrl(body);
515
586
  return this.jsonResponse({ presigned });
516
587
  }
517
588
  }
518
589
  else if (attachmentId === 'upload') {
519
590
  if (method === 'POST') {
520
- const body = await this.readJson(request);
521
- const attachment = await this.engine.uploadAttachmentFile(body);
591
+ const body = await this.readBody(request, schemas.UploadAttachmentSchema);
592
+ const attachment = await engine.uploadAttachmentFile({ ...body, uploaderId: this.requireIdentity(context, body.uploaderId, 'uploaderId') });
522
593
  return this.jsonResponse({ attachment }, 201);
523
594
  }
524
595
  }
@@ -527,24 +598,24 @@ export class CriticalPathRouter {
527
598
  const taskId = url.searchParams.get('taskId') || undefined;
528
599
  const projectId = url.searchParams.get('projectId') || undefined;
529
600
  const commentId = url.searchParams.get('commentId') || undefined;
530
- const attachments = await this.engine.getAttachments({ taskId, projectId, commentId });
601
+ const attachments = await engine.getAttachments({ taskId, projectId, commentId });
531
602
  return this.jsonResponse({ attachments });
532
603
  }
533
604
  if (method === 'POST') {
534
- const body = await this.readJson(request);
535
- const attachment = await this.engine.createAttachment(body);
605
+ const body = await this.readBody(request, schemas.CreateAttachmentSchema);
606
+ const attachment = await engine.createAttachment({ ...body, uploaderId: this.requireIdentity(context, body.uploaderId, 'uploaderId') });
536
607
  return this.jsonResponse({ attachment }, 201);
537
608
  }
538
609
  }
539
610
  else {
540
611
  if (method === 'GET') {
541
- const attachment = await this.engine.getAttachment(attachmentId);
612
+ const attachment = await engine.getAttachment(attachmentId);
542
613
  if (!attachment)
543
614
  return this.jsonResponse({ error: 'Attachment not found' }, 404);
544
615
  return this.jsonResponse({ attachment });
545
616
  }
546
617
  if (method === 'DELETE') {
547
- const deleted = await this.engine.deleteAttachment(attachmentId);
618
+ const deleted = await engine.deleteAttachment(attachmentId);
548
619
  if (!deleted)
549
620
  return this.jsonResponse({ error: 'Not found' }, 404);
550
621
  return this.jsonResponse({ success: true });
@@ -557,12 +628,12 @@ export class CriticalPathRouter {
557
628
  const taskId = url.searchParams.get('taskId');
558
629
  if (!taskId)
559
630
  return this.jsonResponse({ error: 'taskId parameter required' }, 400);
560
- const entries = await this.engine.store.getTimeEntries(taskId);
631
+ const entries = await engine.store.getTimeEntries(taskId);
561
632
  return this.jsonResponse({ timeEntries: entries });
562
633
  }
563
634
  if (method === 'POST') {
564
- const body = await this.readJson(request);
565
- const entry = await this.engine.logTime(body);
635
+ const body = await this.readBody(request, schemas.LogTimeSchema);
636
+ const entry = await engine.logTime(body);
566
637
  return this.jsonResponse({ timeEntry: entry }, 201);
567
638
  }
568
639
  }
@@ -578,7 +649,7 @@ export class CriticalPathRouter {
578
649
  const defaultWeeklyCapacityHours = url.searchParams.get('defaultWeeklyCapacityHours')
579
650
  ? parseFloat(url.searchParams.get('defaultWeeklyCapacityHours'))
580
651
  : undefined;
581
- const workload = await this.engine.getWorkloadDistribution(projectId, {
652
+ const workload = await engine.getWorkloadDistribution(projectId, {
582
653
  startDate,
583
654
  endDate,
584
655
  interval,
@@ -594,8 +665,8 @@ export class CriticalPathRouter {
594
665
  if (method === 'POST') {
595
666
  const body = await this.readJson(request);
596
667
  const status = typeof body.status === 'string' ? body.status : 'active';
597
- if (this.engine.events) {
598
- this.engine.events.publish({
668
+ if (engine.events) {
669
+ engine.events.publish({
599
670
  type: 'agent.status_updated',
600
671
  aggregateId: body.taskId || body.projectId || 'system',
601
672
  payload: {
@@ -621,6 +692,30 @@ export class CriticalPathRouter {
621
692
  return this.errorResponse(err, request);
622
693
  }
623
694
  }
695
+ /**
696
+ * Reads and validates a JSON body. `overrides` (e.g. a `taskId` from the URL) are applied before
697
+ * validation so path parameters always win over body fields.
698
+ */
699
+ async readBody(request, schema, overrides = {}) {
700
+ const raw = await this.readJson(request);
701
+ const body = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
702
+ return schemas.parsePayload(schema, { ...body, ...overrides });
703
+ }
704
+ /**
705
+ * Returns the identity to record for a write: the resolved caller when there is one, otherwise
706
+ * the value supplied in the body. Throws a 400 when neither is present.
707
+ */
708
+ requireIdentity(context, claimed, field) {
709
+ const identity = context?.userId ?? claimed;
710
+ if (!identity)
711
+ throw new BadRequestError(`${field} is required`);
712
+ return identity;
713
+ }
714
+ /** The API root as seen by this request, e.g. `https://host/api/critical-path`. */
715
+ openApiServerUrl(url, subpath) {
716
+ const root = url.pathname.slice(0, url.pathname.length - subpath.length).replace(/\/+$/, '');
717
+ return `${url.origin}${root}`;
718
+ }
624
719
  async readJson(request) {
625
720
  try {
626
721
  return await request.json();
@@ -634,9 +729,10 @@ export class CriticalPathRouter {
634
729
  const e = err;
635
730
  switch (name) {
636
731
  case 'BadRequestError':
637
- case 'ValidationError':
638
732
  case 'AttachmentValidationError':
639
733
  return this.jsonResponse({ error: e.message }, 400);
734
+ case 'ValidationError':
735
+ return this.jsonResponse(e.issues ? { error: e.message, issues: e.issues } : { error: e.message }, 400);
640
736
  case 'WorkflowValidationError':
641
737
  return this.jsonResponse({ error: e.message, fromStatus: e.fromStatus, toStatus: e.toStatus }, 400);
642
738
  case 'CustomFieldValidationError':
@@ -668,8 +764,7 @@ export class CriticalPathRouter {
668
764
  return new Response(JSON.stringify(data), {
669
765
  status,
670
766
  headers: {
671
- 'Content-Type': 'application/json',
672
- ...CORS_HEADERS
767
+ 'Content-Type': 'application/json'
673
768
  }
674
769
  });
675
770
  }