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