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