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