@critical-path/server 0.14.0 → 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,10 +1,34 @@
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
+ }
2
21
 
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
- };
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
+ }
8
32
 
9
33
  class BadRequestError extends Error {
10
34
  constructor(message: string) {
@@ -25,6 +49,21 @@ export interface CriticalPathRouterOptions {
25
49
  * `NODE_ENV === 'development'`, so production deployments never leak internals.
26
50
  */
27
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;
28
67
  }
29
68
 
30
69
  function isDevelopment(): boolean {
@@ -35,6 +74,10 @@ export class CriticalPathRouter {
35
74
  public engine: CriticalPathEngine;
36
75
  private readonly onError?: CriticalPathRouterOptions['onError'];
37
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;
38
81
 
39
82
  constructor(configOrEngine?: CriticalPathConfig | CriticalPathEngine, options: CriticalPathRouterOptions = {}) {
40
83
  if (configOrEngine instanceof CriticalPathEngine) {
@@ -44,21 +87,90 @@ export class CriticalPathRouter {
44
87
  }
45
88
  this.onError = options.onError;
46
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);
47
107
  }
48
108
 
49
- async handleRequest(request: Request): Promise<Response> {
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;
143
+ }
144
+
145
+ private async dispatch(request: Request, init: RequestInitOptions): Promise<Response> {
50
146
  const url = new URL(request.url);
51
147
  const pathname = url.pathname;
52
148
  const method = request.method.toUpperCase();
53
149
 
54
- // Extract subpath after /critical-path/ or /api/critical-path/
55
- 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
+ }
56
154
  const segments = subpath.split('/').filter(Boolean);
57
155
 
58
- // CORS preflight
156
+ // CORS preflight (browsers send it without credentials, so it is answered before auth)
59
157
  if (method === 'OPTIONS') {
60
- return new Response(null, { status: 204, headers: CORS_HEADERS });
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);
61
170
  }
171
+ const engine = context?.userId
172
+ ? this.engine.withActor({ userId: context.userId, username: context.userName, actorType: context.actorType })
173
+ : this.engine;
62
174
 
63
175
  try {
64
176
  // Workflows API
@@ -66,28 +178,28 @@ export class CriticalPathRouter {
66
178
  const workflowId = segments[1];
67
179
  if (!workflowId) {
68
180
  if (method === 'GET') {
69
- const workflows = await this.engine.getWorkflows();
181
+ const workflows = await engine.getWorkflows();
70
182
  return this.jsonResponse({ workflows });
71
183
  }
72
184
  if (method === 'POST') {
73
185
  const body = await this.readJson(request);
74
- const workflow = await this.engine.createWorkflow(body);
186
+ const workflow = await engine.createWorkflow(body);
75
187
  return this.jsonResponse({ workflow }, 201);
76
188
  }
77
189
  } else {
78
190
  if (method === 'GET') {
79
- const workflow = await this.engine.getWorkflow(workflowId);
191
+ const workflow = await engine.getWorkflow(workflowId);
80
192
  if (!workflow) return this.jsonResponse({ error: 'Workflow not found' }, 404);
81
193
  return this.jsonResponse({ workflow });
82
194
  }
83
195
  if (method === 'PATCH' || method === 'PUT') {
84
196
  const body = await this.readJson(request);
85
- const updated = await this.engine.updateWorkflow(workflowId, body);
197
+ const updated = await engine.updateWorkflow(workflowId, body);
86
198
  if (!updated) return this.jsonResponse({ error: 'Workflow not found' }, 404);
87
199
  return this.jsonResponse({ workflow: updated });
88
200
  }
89
201
  if (method === 'DELETE') {
90
- const deleted = await this.engine.deleteWorkflow(workflowId);
202
+ const deleted = await engine.deleteWorkflow(workflowId);
91
203
  if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
92
204
  return this.jsonResponse({ success: true });
93
205
  }
@@ -100,17 +212,17 @@ export class CriticalPathRouter {
100
212
  const subResource = segments[2];
101
213
  if (!projectId) {
102
214
  if (method === 'GET') {
103
- const projects = await this.engine.getProjects();
215
+ const projects = await engine.getProjects();
104
216
  return this.jsonResponse({ projects });
105
217
  }
106
218
  if (method === 'POST') {
107
219
  const body = await this.readJson(request);
108
- const project = await this.engine.createProject(body);
220
+ const project = await engine.createProject(body);
109
221
  return this.jsonResponse({ project }, 201);
110
222
  }
111
223
  } else if (subResource === 'critical-path') {
112
224
  if (method === 'GET') {
113
- const analysis = await this.engine.calculateCriticalPath(projectId);
225
+ const analysis = await engine.calculateCriticalPath(projectId);
114
226
  return this.jsonResponse({ analysis });
115
227
  }
116
228
  } else if (subResource === 'ladder' || subResource === 'timeline-ladder') {
@@ -118,7 +230,7 @@ export class CriticalPathRouter {
118
230
  const level = (url.searchParams.get('level') || 'all') as any;
119
231
  const containerId = url.searchParams.get('containerId') || undefined;
120
232
  const iterationId = url.searchParams.get('iterationId') || undefined;
121
- const ladder = await this.engine.getTimelineLadder(projectId, { level, containerId, iterationId });
233
+ const ladder = await engine.getTimelineLadder(projectId, { level, containerId, iterationId });
122
234
  return this.jsonResponse({ ladder });
123
235
  }
124
236
  } else if (subResource === 'workload' || subResource === 'workload-distribution') {
@@ -132,7 +244,7 @@ export class CriticalPathRouter {
132
244
  ? parseFloat(url.searchParams.get('defaultWeeklyCapacityHours')!)
133
245
  : undefined;
134
246
 
135
- const workload = await this.engine.getWorkloadDistribution(projectId, {
247
+ const workload = await engine.getWorkloadDistribution(projectId, {
136
248
  startDate,
137
249
  endDate,
138
250
  interval,
@@ -144,18 +256,18 @@ export class CriticalPathRouter {
144
256
  }
145
257
  } else {
146
258
  if (method === 'GET') {
147
- const project = await this.engine.getProject(projectId);
259
+ const project = await engine.getProject(projectId);
148
260
  if (!project) return this.jsonResponse({ error: 'Project not found' }, 404);
149
261
  return this.jsonResponse({ project });
150
262
  }
151
263
  if (method === 'PATCH' || method === 'PUT') {
152
264
  const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...updates } = await this.readJson(request);
153
- const project = await this.engine.updateProject(projectId, updates);
265
+ const project = await engine.updateProject(projectId, updates);
154
266
  if (!project) return this.jsonResponse({ error: 'Project not found' }, 404);
155
267
  return this.jsonResponse({ project });
156
268
  }
157
269
  if (method === 'DELETE') {
158
- const deleted = await this.engine.deleteProject(projectId);
270
+ const deleted = await engine.deleteProject(projectId);
159
271
  if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
160
272
  return this.jsonResponse({ success: true });
161
273
  }
@@ -170,37 +282,37 @@ export class CriticalPathRouter {
170
282
  if (!taskId) {
171
283
  if (method === 'GET') {
172
284
  const projectId = url.searchParams.get('projectId') || undefined;
173
- const tasks = await this.engine.getTasks(projectId);
285
+ const tasks = await engine.getTasks(projectId);
174
286
  return this.jsonResponse({ tasks });
175
287
  }
176
288
  if (method === 'POST') {
177
289
  const body = await this.readJson(request);
178
- const task = await this.engine.createTask(body);
290
+ const task = await engine.createTask(body);
179
291
  return this.jsonResponse({ task }, 201);
180
292
  }
181
293
  } else if (subResource === 'comments') {
182
294
  if (method === 'GET') {
183
- const comments = await this.engine.getComments(taskId);
295
+ const comments = await engine.getComments(taskId);
184
296
  return this.jsonResponse({ comments });
185
297
  }
186
298
  if (method === 'POST') {
187
299
  const body = await this.readJson(request);
188
- const comment = await this.engine.addComment({ ...body, taskId });
300
+ const comment = await engine.addComment({ ...body, taskId });
189
301
  return this.jsonResponse({ comment }, 201);
190
302
  }
191
303
  } else if (subResource === 'attachments') {
192
304
  if (method === 'GET') {
193
- const attachments = await this.engine.getAttachments({ taskId });
305
+ const attachments = await engine.getAttachments({ taskId });
194
306
  return this.jsonResponse({ attachments });
195
307
  }
196
308
  if (method === 'POST') {
197
309
  const body = await this.readJson(request);
198
- const attachment = await this.engine.createAttachment({ ...body, taskId });
310
+ const attachment = await engine.createAttachment({ ...body, taskId });
199
311
  return this.jsonResponse({ attachment }, 201);
200
312
  }
201
313
  } else if (subResource === 'dependencies') {
202
314
  if (method === 'GET') {
203
- const graph = await this.engine.getTaskDependencyGraph(taskId);
315
+ const graph = await engine.getTaskDependencyGraph(taskId);
204
316
  return this.jsonResponse({ graph });
205
317
  }
206
318
  if (method === 'POST') {
@@ -208,7 +320,7 @@ export class CriticalPathRouter {
208
320
  if (typeof body.dependsOnTaskId !== 'string' || !body.dependsOnTaskId) {
209
321
  return this.jsonResponse({ error: 'dependsOnTaskId is required' }, 400);
210
322
  }
211
- const dep = await this.engine.addDependency({
323
+ const dep = await engine.addDependency({
212
324
  taskId,
213
325
  dependsOnTaskId: body.dependsOnTaskId,
214
326
  type: body.type || 'blocking'
@@ -217,48 +329,48 @@ export class CriticalPathRouter {
217
329
  }
218
330
  } else if (subResource === 'state' || subResource === 'lifecycle') {
219
331
  if (method === 'GET') {
220
- const state = await this.engine.getTaskLifecycleState(taskId);
332
+ const state = await engine.getTaskLifecycleState(taskId);
221
333
  if (!state) return this.jsonResponse({ error: 'Task not found' }, 404);
222
334
  return this.jsonResponse({ state });
223
335
  }
224
336
  } else if (subResource === 'transitions' || subResource === 'allowed-transitions') {
225
337
  if (method === 'GET') {
226
- const allowedNextStatuses = await this.engine.getAllowedTaskTransitions(taskId);
227
- const allowedPreviousStatuses = await this.engine.getAllowedPreviousTaskTransitions(taskId);
338
+ const allowedNextStatuses = await engine.getAllowedTaskTransitions(taskId);
339
+ const allowedPreviousStatuses = await engine.getAllowedPreviousTaskTransitions(taskId);
228
340
  return this.jsonResponse({ allowedNextStatuses, allowedPreviousStatuses });
229
341
  }
230
342
  } else if (subResource === 'ladder') {
231
343
  if (method === 'GET') {
232
- const taskLadder = await this.engine.getTaskLadder(taskId);
344
+ const taskLadder = await engine.getTaskLadder(taskId);
233
345
  if (!taskLadder) return this.jsonResponse({ error: 'Task not found' }, 404);
234
346
  return this.jsonResponse({ taskLadder });
235
347
  }
236
348
  } else if (subResource === 'metrics') {
237
349
  if (method === 'GET') {
238
- const metrics = await this.engine.getTaskMetrics(taskId);
350
+ const metrics = await engine.getTaskMetrics(taskId);
239
351
  if (!metrics) return this.jsonResponse({ error: 'Task not found' }, 404);
240
352
  return this.jsonResponse({ metrics });
241
353
  }
242
354
  } else if (subResource === 'progress-history') {
243
355
  if (method === 'GET') {
244
- const progressHistory = await this.engine.getTaskProgressHistory(taskId);
356
+ const progressHistory = await engine.getTaskProgressHistory(taskId);
245
357
  if (!progressHistory) return this.jsonResponse({ error: 'Task not found' }, 404);
246
358
  return this.jsonResponse({ progressHistory });
247
359
  }
248
360
  } else {
249
361
  if (method === 'GET') {
250
- const task = await this.engine.getTask(taskId);
362
+ const task = await engine.getTask(taskId);
251
363
  if (!task) return this.jsonResponse({ error: 'Task not found' }, 404);
252
364
  return this.jsonResponse({ task });
253
365
  }
254
366
  if (method === 'PATCH' || method === 'PUT') {
255
367
  const body = await this.readJson(request);
256
- const updated = await this.engine.updateTask(taskId, body);
368
+ const updated = await engine.updateTask(taskId, body);
257
369
  if (!updated) return this.jsonResponse({ error: 'Task not found' }, 404);
258
370
  return this.jsonResponse({ task: updated });
259
371
  }
260
372
  if (method === 'DELETE') {
261
- const deleted = await this.engine.deleteTask(taskId);
373
+ const deleted = await engine.deleteTask(taskId);
262
374
  if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
263
375
  return this.jsonResponse({ success: true });
264
376
  }
@@ -270,28 +382,28 @@ export class CriticalPathRouter {
270
382
  const teamId = segments[1];
271
383
  if (!teamId) {
272
384
  if (method === 'GET') {
273
- const teams = await this.engine.getTeams();
385
+ const teams = await engine.getTeams();
274
386
  return this.jsonResponse({ teams });
275
387
  }
276
388
  if (method === 'POST') {
277
389
  const body = await this.readJson(request);
278
- const team = await this.engine.createTeam(body);
390
+ const team = await engine.createTeam(body);
279
391
  return this.jsonResponse({ team }, 201);
280
392
  }
281
393
  } else {
282
394
  if (method === 'GET') {
283
- const team = await this.engine.getTeam(teamId);
395
+ const team = await engine.getTeam(teamId);
284
396
  if (!team) return this.jsonResponse({ error: 'Team not found' }, 404);
285
397
  return this.jsonResponse({ team });
286
398
  }
287
399
  if (method === 'PATCH' || method === 'PUT') {
288
400
  const body = await this.readJson(request);
289
- const updated = await this.engine.updateTeam(teamId, body);
401
+ const updated = await engine.updateTeam(teamId, body);
290
402
  if (!updated) return this.jsonResponse({ error: 'Team not found' }, 404);
291
403
  return this.jsonResponse({ team: updated });
292
404
  }
293
405
  if (method === 'DELETE') {
294
- const deleted = await this.engine.deleteTeam(teamId);
406
+ const deleted = await engine.deleteTeam(teamId);
295
407
  if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
296
408
  return this.jsonResponse({ success: true });
297
409
  }
@@ -305,28 +417,28 @@ export class CriticalPathRouter {
305
417
  if (method === 'GET') {
306
418
  const projectId = url.searchParams.get('projectId');
307
419
  if (!projectId) return this.jsonResponse({ error: 'projectId parameter required' }, 400);
308
- const containers = await this.engine.getContainers(projectId);
420
+ const containers = await engine.getContainers(projectId);
309
421
  return this.jsonResponse({ containers });
310
422
  }
311
423
  if (method === 'POST') {
312
424
  const body = await this.readJson(request);
313
- const container = await this.engine.createContainer(body);
425
+ const container = await engine.createContainer(body);
314
426
  return this.jsonResponse({ container }, 201);
315
427
  }
316
428
  } else {
317
429
  if (method === 'GET') {
318
- const container = await this.engine.getContainer(containerId);
430
+ const container = await engine.getContainer(containerId);
319
431
  if (!container) return this.jsonResponse({ error: 'Container not found' }, 404);
320
432
  return this.jsonResponse({ container });
321
433
  }
322
434
  if (method === 'PATCH' || method === 'PUT') {
323
435
  const body = await this.readJson(request);
324
- const updated = await this.engine.updateContainer(containerId, body);
436
+ const updated = await engine.updateContainer(containerId, body);
325
437
  if (!updated) return this.jsonResponse({ error: 'Container not found' }, 404);
326
438
  return this.jsonResponse({ container: updated });
327
439
  }
328
440
  if (method === 'DELETE') {
329
- const deleted = await this.engine.deleteContainer(containerId);
441
+ const deleted = await engine.deleteContainer(containerId);
330
442
  if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
331
443
  return this.jsonResponse({ success: true });
332
444
  }
@@ -340,34 +452,34 @@ export class CriticalPathRouter {
340
452
  if (method === 'GET') {
341
453
  const projectId = url.searchParams.get('projectId');
342
454
  if (!projectId) return this.jsonResponse({ error: 'projectId parameter required' }, 400);
343
- const deliverables = await this.engine.getDeliverables(projectId);
455
+ const deliverables = await engine.getDeliverables(projectId);
344
456
  return this.jsonResponse({ deliverables });
345
457
  }
346
458
  if (method === 'POST') {
347
459
  const body = await this.readJson(request);
348
- const deliverable = await this.engine.createDeliverable(body);
460
+ const deliverable = await engine.createDeliverable(body);
349
461
  return this.jsonResponse({ deliverable }, 201);
350
462
  }
351
463
  } else if (segments[2] === 'summary') {
352
464
  if (method === 'GET') {
353
- const summary = await this.engine.getDeliverableSummary(deliverableId);
465
+ const summary = await engine.getDeliverableSummary(deliverableId);
354
466
  if (!summary) return this.jsonResponse({ error: 'Deliverable not found' }, 404);
355
467
  return this.jsonResponse({ summary });
356
468
  }
357
469
  } else {
358
470
  if (method === 'GET') {
359
- const deliverable = await this.engine.getDeliverable(deliverableId);
471
+ const deliverable = await engine.getDeliverable(deliverableId);
360
472
  if (!deliverable) return this.jsonResponse({ error: 'Deliverable not found' }, 404);
361
473
  return this.jsonResponse({ deliverable });
362
474
  }
363
475
  if (method === 'PATCH' || method === 'PUT') {
364
476
  const body = await this.readJson(request);
365
- const updated = await this.engine.updateDeliverable(deliverableId, body);
477
+ const updated = await engine.updateDeliverable(deliverableId, body);
366
478
  if (!updated) return this.jsonResponse({ error: 'Deliverable not found' }, 404);
367
479
  return this.jsonResponse({ deliverable: updated });
368
480
  }
369
481
  if (method === 'DELETE') {
370
- const deleted = await this.engine.deleteDeliverable(deliverableId);
482
+ const deleted = await engine.deleteDeliverable(deliverableId);
371
483
  if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
372
484
  return this.jsonResponse({ success: true });
373
485
  }
@@ -381,28 +493,28 @@ export class CriticalPathRouter {
381
493
  if (method === 'GET') {
382
494
  const projectId = url.searchParams.get('projectId');
383
495
  if (!projectId) return this.jsonResponse({ error: 'projectId parameter required' }, 400);
384
- const iterations = await this.engine.getIterations(projectId);
496
+ const iterations = await engine.getIterations(projectId);
385
497
  return this.jsonResponse({ iterations });
386
498
  }
387
499
  if (method === 'POST') {
388
500
  const body = await this.readJson(request);
389
- const iteration = await this.engine.createIteration(body);
501
+ const iteration = await engine.createIteration(body);
390
502
  return this.jsonResponse({ iteration }, 201);
391
503
  }
392
504
  } else {
393
505
  if (method === 'GET') {
394
- const iteration = await this.engine.getIteration(iterationId);
506
+ const iteration = await engine.getIteration(iterationId);
395
507
  if (!iteration) return this.jsonResponse({ error: 'Iteration not found' }, 404);
396
508
  return this.jsonResponse({ iteration });
397
509
  }
398
510
  if (method === 'PATCH' || method === 'PUT') {
399
511
  const body = await this.readJson(request);
400
- const updated = await this.engine.updateIteration(iterationId, body);
512
+ const updated = await engine.updateIteration(iterationId, body);
401
513
  if (!updated) return this.jsonResponse({ error: 'Iteration not found' }, 404);
402
514
  return this.jsonResponse({ iteration: updated });
403
515
  }
404
516
  if (method === 'DELETE') {
405
- const deleted = await this.engine.deleteIteration(iterationId);
517
+ const deleted = await engine.deleteIteration(iterationId);
406
518
  if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
407
519
  return this.jsonResponse({ success: true });
408
520
  }
@@ -414,7 +526,7 @@ export class CriticalPathRouter {
414
526
  if (method === 'GET') {
415
527
  const projectId = url.searchParams.get('projectId') || undefined;
416
528
  const taskId = url.searchParams.get('taskId') || undefined;
417
- const activities = await this.engine.store.getActivities({ projectId, taskId });
529
+ const activities = await engine.store.getActivities({ projectId, taskId });
418
530
  return this.jsonResponse({ activities });
419
531
  }
420
532
  }
@@ -426,10 +538,11 @@ export class CriticalPathRouter {
426
538
  if (commentId && subResource === 'reactions') {
427
539
  if (method === 'POST') {
428
540
  const body = await this.readJson(request);
429
- if (!body.emoji || !body.userId) {
541
+ const userId = context?.userId ?? body.userId;
542
+ if (!body.emoji || !userId) {
430
543
  return this.jsonResponse({ error: 'emoji and userId are required' }, 400);
431
544
  }
432
- const comment = await this.engine.addCommentReaction(commentId, body);
545
+ const comment = await engine.addCommentReaction(commentId, { emoji: body.emoji, userId });
433
546
  if (!comment) return this.jsonResponse({ error: 'Comment not found' }, 404);
434
547
  return this.jsonResponse({ comment }, 200);
435
548
  }
@@ -441,11 +554,11 @@ export class CriticalPathRouter {
441
554
  // Body may be empty on DELETE, fallback to searchParams
442
555
  }
443
556
  const emoji = body.emoji || url.searchParams.get('emoji');
444
- const userId = body.userId || url.searchParams.get('userId');
557
+ const userId = context?.userId ?? (body.userId || url.searchParams.get('userId'));
445
558
  if (!emoji || !userId) {
446
559
  return this.jsonResponse({ error: 'emoji and userId are required' }, 400);
447
560
  }
448
- const comment = await this.engine.removeCommentReaction(commentId, { emoji, userId });
561
+ const comment = await engine.removeCommentReaction(commentId, { emoji, userId });
449
562
  if (!comment) return this.jsonResponse({ error: 'Comment not found' }, 404);
450
563
  return this.jsonResponse({ comment }, 200);
451
564
  }
@@ -453,28 +566,28 @@ export class CriticalPathRouter {
453
566
  if (method === 'GET') {
454
567
  const taskId = url.searchParams.get('taskId');
455
568
  if (!taskId) return this.jsonResponse({ error: 'taskId parameter required' }, 400);
456
- const comments = await this.engine.getComments(taskId);
569
+ const comments = await engine.getComments(taskId);
457
570
  return this.jsonResponse({ comments });
458
571
  }
459
572
  if (method === 'POST') {
460
573
  const body = await this.readJson(request);
461
- const comment = await this.engine.addComment(body);
574
+ const comment = await engine.addComment(body);
462
575
  return this.jsonResponse({ comment }, 201);
463
576
  }
464
577
  } else {
465
578
  if (method === 'GET') {
466
- const comment = await this.engine.getComment(commentId);
579
+ const comment = await engine.getComment(commentId);
467
580
  if (!comment) return this.jsonResponse({ error: 'Comment not found' }, 404);
468
581
  return this.jsonResponse({ comment });
469
582
  }
470
583
  if (method === 'PATCH' || method === 'PUT') {
471
584
  const body = await this.readJson(request);
472
- const updated = await this.engine.updateComment(commentId, body);
585
+ const updated = await engine.updateComment(commentId, body);
473
586
  if (!updated) return this.jsonResponse({ error: 'Comment not found' }, 404);
474
587
  return this.jsonResponse({ comment: updated });
475
588
  }
476
589
  if (method === 'DELETE') {
477
- const deleted = await this.engine.deleteComment(commentId);
590
+ const deleted = await engine.deleteComment(commentId);
478
591
  if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
479
592
  return this.jsonResponse({ success: true });
480
593
  }
@@ -487,13 +600,13 @@ export class CriticalPathRouter {
487
600
  if (attachmentId === 'presign') {
488
601
  if (method === 'POST') {
489
602
  const body = await this.readJson(request);
490
- const presigned = await this.engine.getPresignedAttachmentUploadUrl(body);
603
+ const presigned = await engine.getPresignedAttachmentUploadUrl(body);
491
604
  return this.jsonResponse({ presigned });
492
605
  }
493
606
  } else if (attachmentId === 'upload') {
494
607
  if (method === 'POST') {
495
608
  const body = await this.readJson(request);
496
- const attachment = await this.engine.uploadAttachmentFile(body);
609
+ const attachment = await engine.uploadAttachmentFile(body);
497
610
  return this.jsonResponse({ attachment }, 201);
498
611
  }
499
612
  } else if (!attachmentId) {
@@ -501,22 +614,22 @@ export class CriticalPathRouter {
501
614
  const taskId = url.searchParams.get('taskId') || undefined;
502
615
  const projectId = url.searchParams.get('projectId') || undefined;
503
616
  const commentId = url.searchParams.get('commentId') || undefined;
504
- const attachments = await this.engine.getAttachments({ taskId, projectId, commentId });
617
+ const attachments = await engine.getAttachments({ taskId, projectId, commentId });
505
618
  return this.jsonResponse({ attachments });
506
619
  }
507
620
  if (method === 'POST') {
508
621
  const body = await this.readJson(request);
509
- const attachment = await this.engine.createAttachment(body);
622
+ const attachment = await engine.createAttachment(body);
510
623
  return this.jsonResponse({ attachment }, 201);
511
624
  }
512
625
  } else {
513
626
  if (method === 'GET') {
514
- const attachment = await this.engine.getAttachment(attachmentId);
627
+ const attachment = await engine.getAttachment(attachmentId);
515
628
  if (!attachment) return this.jsonResponse({ error: 'Attachment not found' }, 404);
516
629
  return this.jsonResponse({ attachment });
517
630
  }
518
631
  if (method === 'DELETE') {
519
- const deleted = await this.engine.deleteAttachment(attachmentId);
632
+ const deleted = await engine.deleteAttachment(attachmentId);
520
633
  if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
521
634
  return this.jsonResponse({ success: true });
522
635
  }
@@ -528,12 +641,12 @@ export class CriticalPathRouter {
528
641
  if (method === 'GET') {
529
642
  const taskId = url.searchParams.get('taskId');
530
643
  if (!taskId) return this.jsonResponse({ error: 'taskId parameter required' }, 400);
531
- const entries = await this.engine.store.getTimeEntries(taskId);
644
+ const entries = await engine.store.getTimeEntries(taskId);
532
645
  return this.jsonResponse({ timeEntries: entries });
533
646
  }
534
647
  if (method === 'POST') {
535
648
  const body = await this.readJson(request);
536
- const entry = await this.engine.logTime(body);
649
+ const entry = await engine.logTime(body);
537
650
  return this.jsonResponse({ timeEntry: entry }, 201);
538
651
  }
539
652
  }
@@ -551,7 +664,7 @@ export class CriticalPathRouter {
551
664
  ? parseFloat(url.searchParams.get('defaultWeeklyCapacityHours')!)
552
665
  : undefined;
553
666
 
554
- const workload = await this.engine.getWorkloadDistribution(projectId, {
667
+ const workload = await engine.getWorkloadDistribution(projectId, {
555
668
  startDate,
556
669
  endDate,
557
670
  interval,
@@ -568,8 +681,8 @@ export class CriticalPathRouter {
568
681
  if (method === 'POST') {
569
682
  const body = await this.readJson(request);
570
683
  const status = typeof body.status === 'string' ? body.status : 'active';
571
- if (this.engine.events) {
572
- this.engine.events.publish({
684
+ if (engine.events) {
685
+ engine.events.publish({
573
686
  type: 'agent.status_updated' as any,
574
687
  aggregateId: body.taskId || body.projectId || 'system',
575
688
  payload: {
@@ -644,8 +757,7 @@ export class CriticalPathRouter {
644
757
  return new Response(JSON.stringify(data), {
645
758
  status,
646
759
  headers: {
647
- 'Content-Type': 'application/json',
648
- ...CORS_HEADERS
760
+ 'Content-Type': 'application/json'
649
761
  }
650
762
  });
651
763
  }