@critical-path/server 0.23.4 → 0.24.1

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 DELETED
@@ -1,895 +0,0 @@
1
- import { CriticalPathEngine, type AuthorType, type CriticalPathConfig, type PluginRoute } from '@critical-path/core';
2
- import * as schemas from '@critical-path/core/schemas';
3
- import { buildOpenApiDocument } from './openapi.js';
4
-
5
- /** Actor for requests when no `getContext` is configured or it resolves no user. */
6
- export const ANONYMOUS_ACTOR = Object.freeze({ userId: 'anonymous', username: 'Anonymous', actorType: 'user' as const });
7
-
8
- const CORS_ALLOW_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
9
- const DEFAULT_CORS_ALLOW_HEADERS = ['Content-Type', 'Authorization'];
10
-
11
- /**
12
- * Per-request identity resolved by `getContext`. `userId` becomes the actor every mutation in
13
- * the request is attributed to. Extra fields (e.g. `tenantId`, `roles`) are carried for
14
- * future authorization hooks.
15
- */
16
- export interface RequestContext {
17
- userId?: string;
18
- userName?: string;
19
- actorType?: AuthorType;
20
- /** Tenant the caller belongs to; scopes projects, workflows and teams (see `Actor.tenantId`). */
21
- tenantId?: string;
22
- /** Workspace roles, e.g. `['admin']`, interpreted by the engine's `authorize` policy. */
23
- roles?: string[];
24
- [key: string]: unknown;
25
- }
26
-
27
- export interface RequestInitOptions {
28
- getContext?: CriticalPathRouterOptions['getContext'];
29
- }
30
-
31
- export interface CorsOptions {
32
- /** Allowed origins. `'*'` allows any origin (and cannot be combined with `credentials`). */
33
- origins: '*' | string[];
34
- /** Send `Access-Control-Allow-Credentials: true` for allowed origins. */
35
- credentials?: boolean;
36
- /** Request headers browsers may send. Defaults to `Content-Type` and `Authorization`. */
37
- allowHeaders?: string[];
38
- /** Seconds browsers may cache preflight results. */
39
- maxAge?: number;
40
- }
41
-
42
- class BadRequestError extends Error {
43
- constructor(message: string) {
44
- super(message);
45
- this.name = 'BadRequestError';
46
- }
47
- }
48
-
49
- export interface CriticalPathRouterOptions {
50
- /**
51
- * Called for unexpected errors (those that would produce a 500), e.g. to report to Sentry.
52
- * Return a `Response` to replace the default 500 response. When omitted, errors are logged
53
- * with `console.error`.
54
- */
55
- onError?: (error: unknown, request: Request) => void | Response | Promise<void | Response>;
56
- /**
57
- * Include the real error message in 500 responses. Defaults to `true` only when
58
- * `NODE_ENV === 'development'`, so production deployments never leak internals.
59
- */
60
- exposeErrors?: boolean;
61
- /**
62
- * Resolves the caller's identity from the request (session cookie, bearer token, etc.).
63
- * When it returns a `userId`, all mutations in the request are attributed to that user;
64
- * otherwise they are attributed to `ANONYMOUS_ACTOR`. Request bodies never carry identity.
65
- */
66
- getContext?: (request: Request) => RequestContext | null | undefined | Promise<RequestContext | null | undefined>;
67
- /** Reject requests with `401` unless `getContext` returns a `userId`. Defaults to `false`. */
68
- requireAuth?: boolean;
69
- /**
70
- * The path the router is mounted at, e.g. `/api/pm`. Requests outside it return `404`.
71
- * When omitted, everything up to the first `/critical-path` segment is stripped.
72
- */
73
- basePath?: string;
74
- /**
75
- * CORS policy. Defaults to `false` (no CORS headers, so only same-origin browsers can call the
76
- * API). Set `{ origins: [...] }` to allow specific sites, or `{ origins: '*' }` for any site.
77
- */
78
- cors?: CorsOptions | false;
79
- }
80
-
81
- function isDevelopment(): boolean {
82
- return (globalThis as any).process?.env?.NODE_ENV === 'development';
83
- }
84
-
85
- export class CriticalPathRouter {
86
- public engine: CriticalPathEngine;
87
- private readonly onError?: CriticalPathRouterOptions['onError'];
88
- private readonly exposeErrors: boolean;
89
- private readonly getContext?: CriticalPathRouterOptions['getContext'];
90
- private readonly requireAuth: boolean;
91
- private readonly basePath?: string;
92
- private readonly cors: CorsOptions | false;
93
-
94
- constructor(configOrEngine?: CriticalPathConfig | CriticalPathEngine, options: CriticalPathRouterOptions = {}) {
95
- if (configOrEngine instanceof CriticalPathEngine) {
96
- this.engine = configOrEngine;
97
- } else {
98
- this.engine = new CriticalPathEngine(configOrEngine);
99
- }
100
- this.onError = options.onError;
101
- this.exposeErrors = options.exposeErrors ?? isDevelopment();
102
- this.getContext = options.getContext;
103
- this.requireAuth = options.requireAuth ?? false;
104
- this.basePath = options.basePath ? '/' + options.basePath.replace(/^\/+|\/+$/g, '') : undefined;
105
- this.cors = options.cors ?? false;
106
- if (this.cors && this.cors.origins === '*' && this.cors.credentials) {
107
- throw new Error('CORS credentials cannot be combined with origins "*"; list the allowed origins instead.');
108
- }
109
- }
110
-
111
- /**
112
- * Handles a request. Adapters that resolve identity from something other than the `Request`
113
- * (e.g. SvelteKit `locals`) pass `init.getContext`, which replaces the router's `getContext`
114
- * for this request.
115
- */
116
- async handleRequest(request: Request, init: RequestInitOptions = {}): Promise<Response> {
117
- const response = await this.dispatch(request, init);
118
- return this.applyCors(request, response);
119
- }
120
-
121
- /** Returns the path after the mount point, or `null` when the request is outside `basePath`. */
122
- private resolveSubpath(pathname: string): string | null {
123
- if (this.basePath) {
124
- if (pathname !== this.basePath && !pathname.startsWith(this.basePath + '/')) return null;
125
- return pathname.slice(this.basePath.length).replace(/^\/+/, '');
126
- }
127
- // Extract subpath after /critical-path/ or /api/critical-path/
128
- return pathname.replace(/^.*?\/critical-path\/?/, '').replace(/^\/+/, '');
129
- }
130
-
131
- private applyCors(request: Request, response: Response): Response {
132
- if (!this.cors) return response;
133
-
134
- const origin = request.headers.get('Origin');
135
- let allowOrigin: string | undefined;
136
- if (this.cors.origins === '*') {
137
- allowOrigin = '*';
138
- } else if (origin && this.cors.origins.includes(origin)) {
139
- allowOrigin = origin;
140
- }
141
-
142
- // Copy so headers are mutable even if a hook returned a Response with immutable headers.
143
- const result = new Response(response.body, response);
144
- if (this.cors.origins !== '*') result.headers.append('Vary', 'Origin');
145
- if (!allowOrigin) return result;
146
-
147
- result.headers.set('Access-Control-Allow-Origin', allowOrigin);
148
- result.headers.set('Access-Control-Allow-Methods', CORS_ALLOW_METHODS);
149
- result.headers.set('Access-Control-Allow-Headers', (this.cors.allowHeaders ?? DEFAULT_CORS_ALLOW_HEADERS).join(', '));
150
- if (this.cors.credentials) result.headers.set('Access-Control-Allow-Credentials', 'true');
151
- if (request.method.toUpperCase() === 'OPTIONS' && this.cors.maxAge !== undefined) {
152
- result.headers.set('Access-Control-Max-Age', String(this.cors.maxAge));
153
- }
154
- return result;
155
- }
156
-
157
- private async dispatch(request: Request, init: RequestInitOptions): Promise<Response> {
158
- const url = new URL(request.url);
159
- const pathname = url.pathname;
160
- const method = request.method.toUpperCase();
161
-
162
- const subpath = this.resolveSubpath(pathname);
163
- if (subpath === null) {
164
- return this.jsonResponse({ error: `Route not found: ${method} ${pathname}` }, 404);
165
- }
166
- const segments = subpath.split('/').filter(Boolean);
167
-
168
- // CORS preflight (browsers send it without credentials, so it is answered before auth)
169
- if (method === 'OPTIONS') {
170
- return new Response(null, { status: 204 });
171
- }
172
-
173
- let context: RequestContext | null | undefined;
174
- try {
175
- const resolve = init.getContext ?? this.getContext;
176
- context = resolve ? await resolve(request) : undefined;
177
- } catch (err) {
178
- return this.errorResponse(err, request);
179
- }
180
- if (this.requireAuth && !context?.userId) {
181
- return this.jsonResponse({ error: 'Authentication required' }, 401);
182
- }
183
- // Every request runs as an actor, so authorship never comes from request bodies.
184
- const engine = this.engine.withActor(
185
- context?.userId
186
- ? {
187
- userId: context.userId,
188
- username: context.userName,
189
- actorType: context.actorType,
190
- tenantId: context.tenantId,
191
- roles: context.roles
192
- }
193
- : { ...ANONYMOUS_ACTOR, tenantId: context?.tenantId }
194
- );
195
-
196
- try {
197
- // Plugin init (and seeding) must finish before any request is served.
198
- await this.engine.ready;
199
- const pluginContext = { engine, context: context ?? undefined, url };
200
- const handle = async (): Promise<Response> => {
201
- const matched = this.matchPluginRoute(method, segments);
202
- if (matched) return matched.route.handler(request, { ...pluginContext, params: matched.params });
203
- return this.builtInRoutes(request, { url, pathname, method, subpath, segments, engine, context });
204
- };
205
- // Plugin middleware wraps routing, in registration order (first registered runs outermost).
206
- const chain = this.engine.plugins
207
- .getMiddleware()
208
- .reduceRight<() => Promise<Response>>(
209
- (next, middleware) => async () => middleware(request, pluginContext, next),
210
- handle
211
- );
212
- return await chain();
213
- } catch (err: unknown) {
214
- return this.errorResponse(err, request);
215
- }
216
- }
217
-
218
- private matchPluginRoute(
219
- method: string,
220
- segments: string[]
221
- ): { route: PluginRoute; params: Record<string, string> } | null {
222
- for (const route of this.engine.plugins.getRoutes()) {
223
- if (route.method !== method && !(route.method === 'PATCH' && method === 'PUT')) continue;
224
- const pattern = route.path.split('/').filter(Boolean);
225
- if (pattern.length !== segments.length) continue;
226
- const params: Record<string, string> = {};
227
- const matches = pattern.every((part, i) => {
228
- if (part.startsWith(':')) {
229
- params[part.slice(1)] = decodeURIComponent(segments[i]);
230
- return true;
231
- }
232
- return part === segments[i];
233
- });
234
- if (matches) return { route, params };
235
- }
236
- return null;
237
- }
238
-
239
- private async builtInRoutes(
240
- request: Request,
241
- { url, pathname, method, subpath, segments, engine, context }: {
242
- url: URL;
243
- pathname: string;
244
- method: string;
245
- subpath: string;
246
- segments: string[];
247
- engine: CriticalPathEngine;
248
- context: RequestContext | null | undefined;
249
- }
250
- ): Promise<Response> {
251
- // OpenAPI contract (behind the same auth as every other route)
252
- if (segments[0] === 'openapi.json' && segments.length === 1 && method === 'GET') {
253
- return this.jsonResponse(buildOpenApiDocument({ serverUrl: this.openApiServerUrl(url, subpath) }));
254
- }
255
-
256
- // Webhooks API
257
- if (segments[0] === 'webhooks') {
258
- const webhookId = segments[1];
259
- if (!webhookId) {
260
- if (method === 'GET') {
261
- return this.jsonResponse({ webhooks: await engine.getWebhooks() });
262
- }
263
- if (method === 'POST') {
264
- const body = await this.readBody(request, schemas.CreateWebhookSchema);
265
- const { webhook, secret } = await engine.createWebhook(body);
266
- return this.jsonResponse({ webhook, secret }, 201);
267
- }
268
- } else {
269
- if (method === 'GET') {
270
- const webhook = await engine.getWebhook(webhookId);
271
- if (!webhook) return this.jsonResponse({ error: 'Webhook not found' }, 404);
272
- return this.jsonResponse({ webhook });
273
- }
274
- if (method === 'PATCH' || method === 'PUT') {
275
- const body = await this.readBody(request, schemas.UpdateWebhookSchema);
276
- const webhook = await engine.updateWebhook(webhookId, body);
277
- if (!webhook) return this.jsonResponse({ error: 'Webhook not found' }, 404);
278
- return this.jsonResponse({ webhook });
279
- }
280
- if (method === 'DELETE') {
281
- const deleted = await engine.deleteWebhook(webhookId);
282
- if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
283
- return this.jsonResponse({ success: true });
284
- }
285
- }
286
- }
287
-
288
- // Workflows API
289
- if (segments[0] === 'workflows') {
290
- const workflowId = segments[1];
291
- if (!workflowId) {
292
- if (method === 'GET') {
293
- const workflows = await engine.getWorkflows();
294
- return this.jsonResponse({ workflows });
295
- }
296
- if (method === 'POST') {
297
- const body = await this.readBody(request, schemas.CreateWorkflowSchema);
298
- const workflow = await engine.createWorkflow(body);
299
- return this.jsonResponse({ workflow }, 201);
300
- }
301
- } else {
302
- if (method === 'GET') {
303
- const workflow = await engine.getWorkflow(workflowId);
304
- if (!workflow) return this.jsonResponse({ error: 'Workflow not found' }, 404);
305
- return this.jsonResponse({ workflow });
306
- }
307
- if (method === 'PATCH' || method === 'PUT') {
308
- const body = await this.readBody(request, schemas.UpdateWorkflowSchema);
309
- const updated = await engine.updateWorkflow(workflowId, body);
310
- if (!updated) return this.jsonResponse({ error: 'Workflow not found' }, 404);
311
- return this.jsonResponse({ workflow: updated });
312
- }
313
- if (method === 'DELETE') {
314
- const deleted = await engine.deleteWorkflow(workflowId);
315
- if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
316
- return this.jsonResponse({ success: true });
317
- }
318
- }
319
- }
320
-
321
- // Projects API
322
- if (segments[0] === 'projects') {
323
- const projectId = segments[1];
324
- const subResource = segments[2];
325
- if (!projectId) {
326
- if (method === 'GET') {
327
- const projects = await engine.getProjects();
328
- return this.jsonResponse({ projects });
329
- }
330
- if (method === 'POST') {
331
- const body = await this.readBody(request, schemas.CreateProjectSchema);
332
- const project = await engine.createProject(body);
333
- return this.jsonResponse({ project }, 201);
334
- }
335
- } else if (subResource === 'critical-path') {
336
- if (method === 'GET') {
337
- const analysis = await engine.calculateCriticalPath(projectId);
338
- return this.jsonResponse({ analysis });
339
- }
340
- } else if (subResource === 'ladder' || subResource === 'timeline-ladder') {
341
- if (method === 'GET') {
342
- const level = (url.searchParams.get('level') || 'all') as any;
343
- const containerId = url.searchParams.get('containerId') || undefined;
344
- const iterationId = url.searchParams.get('iterationId') || undefined;
345
- const ladder = await engine.getTimelineLadder(projectId, { level, containerId, iterationId });
346
- return this.jsonResponse({ ladder });
347
- }
348
- } else if (subResource === 'workload' || subResource === 'workload-distribution') {
349
- if (method === 'GET') {
350
- const startDate = url.searchParams.get('startDate') || undefined;
351
- const endDate = url.searchParams.get('endDate') || undefined;
352
- const interval = (url.searchParams.get('interval') || undefined) as any;
353
- const groupBy = (url.searchParams.get('groupBy') || undefined) as any;
354
- const metric = (url.searchParams.get('metric') || undefined) as any;
355
- const defaultWeeklyCapacityHours = url.searchParams.get('defaultWeeklyCapacityHours')
356
- ? parseFloat(url.searchParams.get('defaultWeeklyCapacityHours')!)
357
- : undefined;
358
-
359
- const workload = await engine.getWorkloadDistribution(projectId, {
360
- startDate,
361
- endDate,
362
- interval,
363
- groupBy,
364
- metric,
365
- defaultWeeklyCapacityHours
366
- });
367
- return this.jsonResponse({ workload });
368
- }
369
- } else {
370
- if (method === 'GET') {
371
- const project = await engine.getProject(projectId);
372
- if (!project) return this.jsonResponse({ error: 'Project not found' }, 404);
373
- return this.jsonResponse({ project });
374
- }
375
- if (method === 'PATCH' || method === 'PUT') {
376
- const updates = await this.readBody(request, schemas.UpdateProjectSchema);
377
- const project = await engine.updateProject(projectId, updates);
378
- if (!project) return this.jsonResponse({ error: 'Project not found' }, 404);
379
- return this.jsonResponse({ project });
380
- }
381
- if (method === 'DELETE') {
382
- const deleted = await engine.deleteProject(projectId);
383
- if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
384
- return this.jsonResponse({ success: true });
385
- }
386
- }
387
- }
388
-
389
- // Tasks API & Task Sub-resources (Dependencies, Lifecycle State, Transitions)
390
- if (segments[0] === 'tasks') {
391
- const taskId = segments[1];
392
- const subResource = segments[2];
393
-
394
- if (!taskId) {
395
- if (method === 'GET') {
396
- const query = schemas.parsePayload(schemas.TaskListQuerySchema, Object.fromEntries(url.searchParams));
397
- const page = await engine.queryTasks(query);
398
- return this.jsonResponse({ tasks: page.items, nextCursor: page.nextCursor });
399
- }
400
- if (method === 'POST') {
401
- const body = await this.readBody(request, schemas.CreateTaskSchema);
402
- const task = await engine.createTask(body);
403
- return this.jsonResponse({ task }, 201);
404
- }
405
- } else if (subResource === 'comments') {
406
- if (method === 'GET') {
407
- const comments = await engine.getComments(taskId);
408
- return this.jsonResponse({ comments });
409
- }
410
- if (method === 'POST') {
411
- const body = await this.readBody(request, schemas.CreateCommentSchema, { taskId });
412
- const comment = await engine.addComment(body);
413
- return this.jsonResponse({ comment }, 201);
414
- }
415
- } else if (subResource === 'attachments') {
416
- if (method === 'GET') {
417
- const attachments = await engine.getAttachments({ taskId });
418
- return this.jsonResponse({ attachments });
419
- }
420
- if (method === 'POST') {
421
- const body = await this.readBody(request, schemas.CreateAttachmentSchema, { taskId });
422
- const attachment = await engine.createAttachment(body);
423
- return this.jsonResponse({ attachment }, 201);
424
- }
425
- } else if (subResource === 'dependencies' && segments[3]) {
426
- if (method === 'DELETE') {
427
- // The dependency must involve this task (as dependent or upstream)
428
- const dependency = await engine.store.getDependency(segments[3]);
429
- const belongs = dependency && (dependency.taskId === taskId || dependency.dependsOnTaskId === taskId);
430
- if (!belongs || !(await engine.removeDependency(segments[3]))) {
431
- return this.jsonResponse({ error: 'Not found' }, 404);
432
- }
433
- return this.jsonResponse({ success: true });
434
- }
435
- } else if (subResource === 'dependencies') {
436
- if (method === 'GET') {
437
- const graph = await engine.getTaskDependencyGraph(taskId);
438
- return this.jsonResponse({ graph });
439
- }
440
- if (method === 'POST') {
441
- const body = await this.readBody(request, schemas.CreateDependencySchema);
442
- const dep = await engine.addDependency({
443
- taskId,
444
- dependsOnTaskId: body.dependsOnTaskId,
445
- type: body.type || 'blocking'
446
- });
447
- return this.jsonResponse({ dependency: dep }, 201);
448
- }
449
- } else if (subResource === 'state' || subResource === 'lifecycle') {
450
- if (method === 'GET') {
451
- const state = await engine.getTaskLifecycleState(taskId);
452
- if (!state) return this.jsonResponse({ error: 'Task not found' }, 404);
453
- return this.jsonResponse({ state });
454
- }
455
- } else if (subResource === 'transitions' || subResource === 'allowed-transitions') {
456
- if (method === 'GET') {
457
- const allowedNextStatuses = await engine.getAllowedTaskTransitions(taskId);
458
- const allowedPreviousStatuses = await engine.getAllowedPreviousTaskTransitions(taskId);
459
- return this.jsonResponse({ allowedNextStatuses, allowedPreviousStatuses });
460
- }
461
- } else if (subResource === 'ladder') {
462
- if (method === 'GET') {
463
- const taskLadder = await engine.getTaskLadder(taskId);
464
- if (!taskLadder) return this.jsonResponse({ error: 'Task not found' }, 404);
465
- return this.jsonResponse({ taskLadder });
466
- }
467
- } else if (subResource === 'metrics') {
468
- if (method === 'GET') {
469
- const metrics = await engine.getTaskMetrics(taskId);
470
- if (!metrics) return this.jsonResponse({ error: 'Task not found' }, 404);
471
- return this.jsonResponse({ metrics });
472
- }
473
- } else if (subResource === 'progress-history') {
474
- if (method === 'GET') {
475
- const progressHistory = await engine.getTaskProgressHistory(taskId);
476
- if (!progressHistory) return this.jsonResponse({ error: 'Task not found' }, 404);
477
- return this.jsonResponse({ progressHistory });
478
- }
479
- } else {
480
- if (method === 'GET') {
481
- const task = await engine.getTask(taskId);
482
- if (!task) return this.jsonResponse({ error: 'Task not found' }, 404);
483
- return this.jsonResponse({ task });
484
- }
485
- if (method === 'PATCH' || method === 'PUT') {
486
- const body = await this.readBody(request, schemas.UpdateTaskSchema);
487
- const updated = await engine.updateTask(taskId, body);
488
- if (!updated) return this.jsonResponse({ error: 'Task not found' }, 404);
489
- return this.jsonResponse({ task: updated });
490
- }
491
- if (method === 'DELETE') {
492
- const subtasks = url.searchParams.get('subtasks');
493
- if (subtasks && subtasks !== 'delete' && subtasks !== 'detach') {
494
- return this.jsonResponse({ error: 'subtasks must be "delete" or "detach"' }, 400);
495
- }
496
- const deleted = await engine.deleteTask(taskId, { subtasks: (subtasks as 'delete' | 'detach' | null) ?? 'delete' });
497
- if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
498
- return this.jsonResponse({ success: true });
499
- }
500
- }
501
- }
502
-
503
- // Teams API
504
- if (segments[0] === 'teams') {
505
- const teamId = segments[1];
506
- if (!teamId) {
507
- if (method === 'GET') {
508
- const teams = await engine.getTeams();
509
- return this.jsonResponse({ teams });
510
- }
511
- if (method === 'POST') {
512
- const body = await this.readBody(request, schemas.CreateTeamSchema);
513
- const team = await engine.createTeam(body);
514
- return this.jsonResponse({ team }, 201);
515
- }
516
- } else {
517
- if (method === 'GET') {
518
- const team = await engine.getTeam(teamId);
519
- if (!team) return this.jsonResponse({ error: 'Team not found' }, 404);
520
- return this.jsonResponse({ team });
521
- }
522
- if (method === 'PATCH' || method === 'PUT') {
523
- const body = await this.readBody(request, schemas.UpdateTeamSchema);
524
- const updated = await engine.updateTeam(teamId, body);
525
- if (!updated) return this.jsonResponse({ error: 'Team not found' }, 404);
526
- return this.jsonResponse({ team: updated });
527
- }
528
- if (method === 'DELETE') {
529
- const deleted = await engine.deleteTeam(teamId);
530
- if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
531
- return this.jsonResponse({ success: true });
532
- }
533
- }
534
- }
535
-
536
- // Task Containers API
537
- if (segments[0] === 'containers') {
538
- const containerId = segments[1];
539
- if (!containerId) {
540
- if (method === 'GET') {
541
- const projectId = url.searchParams.get('projectId');
542
- if (!projectId) return this.jsonResponse({ error: 'projectId parameter required' }, 400);
543
- const containers = await engine.getContainers(projectId);
544
- return this.jsonResponse({ containers });
545
- }
546
- if (method === 'POST') {
547
- const body = await this.readBody(request, schemas.CreateContainerSchema);
548
- const container = await engine.createContainer(body);
549
- return this.jsonResponse({ container }, 201);
550
- }
551
- } else {
552
- if (method === 'GET') {
553
- const container = await engine.getContainer(containerId);
554
- if (!container) return this.jsonResponse({ error: 'Container not found' }, 404);
555
- return this.jsonResponse({ container });
556
- }
557
- if (method === 'PATCH' || method === 'PUT') {
558
- const body = await this.readBody(request, schemas.UpdateContainerSchema);
559
- const updated = await engine.updateContainer(containerId, body);
560
- if (!updated) return this.jsonResponse({ error: 'Container not found' }, 404);
561
- return this.jsonResponse({ container: updated });
562
- }
563
- if (method === 'DELETE') {
564
- const deleted = await engine.deleteContainer(containerId);
565
- if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
566
- return this.jsonResponse({ success: true });
567
- }
568
- }
569
- }
570
-
571
- // Deliverables API
572
- if (segments[0] === 'deliverables') {
573
- const deliverableId = segments[1];
574
- if (!deliverableId) {
575
- if (method === 'GET') {
576
- const projectId = url.searchParams.get('projectId');
577
- if (!projectId) return this.jsonResponse({ error: 'projectId parameter required' }, 400);
578
- const deliverables = await engine.getDeliverables(projectId);
579
- return this.jsonResponse({ deliverables });
580
- }
581
- if (method === 'POST') {
582
- const body = await this.readBody(request, schemas.CreateDeliverableSchema);
583
- const deliverable = await engine.createDeliverable(body);
584
- return this.jsonResponse({ deliverable }, 201);
585
- }
586
- } else if (segments[2] === 'summary') {
587
- if (method === 'GET') {
588
- const summary = await engine.getDeliverableSummary(deliverableId);
589
- if (!summary) return this.jsonResponse({ error: 'Deliverable not found' }, 404);
590
- return this.jsonResponse({ summary });
591
- }
592
- } else {
593
- if (method === 'GET') {
594
- const deliverable = await engine.getDeliverable(deliverableId);
595
- if (!deliverable) return this.jsonResponse({ error: 'Deliverable not found' }, 404);
596
- return this.jsonResponse({ deliverable });
597
- }
598
- if (method === 'PATCH' || method === 'PUT') {
599
- const body = await this.readBody(request, schemas.UpdateDeliverableSchema);
600
- const updated = await engine.updateDeliverable(deliverableId, body);
601
- if (!updated) return this.jsonResponse({ error: 'Deliverable not found' }, 404);
602
- return this.jsonResponse({ deliverable: updated });
603
- }
604
- if (method === 'DELETE') {
605
- const deleted = await engine.deleteDeliverable(deliverableId);
606
- if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
607
- return this.jsonResponse({ success: true });
608
- }
609
- }
610
- }
611
-
612
- // Iterations API
613
- if (segments[0] === 'iterations') {
614
- const iterationId = segments[1];
615
- if (!iterationId) {
616
- if (method === 'GET') {
617
- const projectId = url.searchParams.get('projectId');
618
- if (!projectId) return this.jsonResponse({ error: 'projectId parameter required' }, 400);
619
- const iterations = await engine.getIterations(projectId);
620
- return this.jsonResponse({ iterations });
621
- }
622
- if (method === 'POST') {
623
- const body = await this.readBody(request, schemas.CreateIterationSchema);
624
- const iteration = await engine.createIteration(body);
625
- return this.jsonResponse({ iteration }, 201);
626
- }
627
- } else {
628
- if (method === 'GET') {
629
- const iteration = await engine.getIteration(iterationId);
630
- if (!iteration) return this.jsonResponse({ error: 'Iteration not found' }, 404);
631
- return this.jsonResponse({ iteration });
632
- }
633
- if (method === 'PATCH' || method === 'PUT') {
634
- const body = await this.readBody(request, schemas.UpdateIterationSchema);
635
- const updated = await engine.updateIteration(iterationId, body);
636
- if (!updated) return this.jsonResponse({ error: 'Iteration not found' }, 404);
637
- return this.jsonResponse({ iteration: updated });
638
- }
639
- if (method === 'DELETE') {
640
- const deleted = await engine.deleteIteration(iterationId);
641
- if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
642
- return this.jsonResponse({ success: true });
643
- }
644
- }
645
- }
646
-
647
- // Activities API
648
- if (segments[0] === 'activities') {
649
- if (method === 'GET') {
650
- const query = schemas.parsePayload(schemas.ActivityListQuerySchema, Object.fromEntries(url.searchParams));
651
- const page = await engine.queryActivities(query);
652
- return this.jsonResponse({ activities: page.items, nextCursor: page.nextCursor });
653
- }
654
- }
655
-
656
- // Comments API
657
- if (segments[0] === 'comments') {
658
- const commentId = segments[1];
659
- const subResource = segments[2];
660
- if (commentId && subResource === 'reactions') {
661
- if (method === 'POST') {
662
- const body = await this.readBody(request, schemas.CommentReactionSchema);
663
- const comment = await engine.addCommentReaction(commentId, body);
664
- if (!comment) return this.jsonResponse({ error: 'Comment not found' }, 404);
665
- return this.jsonResponse({ comment }, 200);
666
- }
667
- if (method === 'DELETE') {
668
- const emoji = url.searchParams.get('emoji');
669
- if (!emoji) {
670
- return this.jsonResponse({ error: 'emoji query parameter is required' }, 400);
671
- }
672
- const comment = await engine.removeCommentReaction(commentId, { emoji });
673
- if (!comment) return this.jsonResponse({ error: 'Comment not found' }, 404);
674
- return this.jsonResponse({ comment }, 200);
675
- }
676
- } else if (!commentId) {
677
- if (method === 'GET') {
678
- const taskId = url.searchParams.get('taskId');
679
- if (!taskId) return this.jsonResponse({ error: 'taskId parameter required' }, 400);
680
- const comments = await engine.getComments(taskId);
681
- return this.jsonResponse({ comments });
682
- }
683
- if (method === 'POST') {
684
- const body = await this.readBody(request, schemas.CreateCommentSchema);
685
- const comment = await engine.addComment(body);
686
- return this.jsonResponse({ comment }, 201);
687
- }
688
- } else {
689
- if (method === 'GET') {
690
- const comment = await engine.getComment(commentId);
691
- if (!comment) return this.jsonResponse({ error: 'Comment not found' }, 404);
692
- return this.jsonResponse({ comment });
693
- }
694
- if (method === 'PATCH' || method === 'PUT') {
695
- const body = await this.readBody(request, schemas.UpdateCommentSchema);
696
- const updated = await engine.updateComment(commentId, body);
697
- if (!updated) return this.jsonResponse({ error: 'Comment not found' }, 404);
698
- return this.jsonResponse({ comment: updated });
699
- }
700
- if (method === 'DELETE') {
701
- const deleted = await engine.deleteComment(commentId);
702
- if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
703
- return this.jsonResponse({ success: true });
704
- }
705
- }
706
- }
707
-
708
- // Attachments API
709
- if (segments[0] === 'attachments') {
710
- const attachmentId = segments[1];
711
- if (attachmentId === 'presign') {
712
- if (method === 'POST') {
713
- const body = await this.readBody(request, schemas.PresignAttachmentSchema);
714
- const presigned = await engine.getPresignedAttachmentUploadUrl(body);
715
- return this.jsonResponse({ presigned });
716
- }
717
- } else if (attachmentId === 'upload') {
718
- if (method === 'POST') {
719
- const body = await this.readBody(request, schemas.UploadAttachmentSchema);
720
- const attachment = await engine.uploadAttachmentFile(body);
721
- return this.jsonResponse({ attachment }, 201);
722
- }
723
- } else if (!attachmentId) {
724
- if (method === 'GET') {
725
- const taskId = url.searchParams.get('taskId') || undefined;
726
- const projectId = url.searchParams.get('projectId') || undefined;
727
- const commentId = url.searchParams.get('commentId') || undefined;
728
- const attachments = await engine.getAttachments({ taskId, projectId, commentId });
729
- return this.jsonResponse({ attachments });
730
- }
731
- if (method === 'POST') {
732
- const body = await this.readBody(request, schemas.CreateAttachmentSchema);
733
- const attachment = await engine.createAttachment(body);
734
- return this.jsonResponse({ attachment }, 201);
735
- }
736
- } else {
737
- if (method === 'GET') {
738
- const attachment = await engine.getAttachment(attachmentId);
739
- if (!attachment) return this.jsonResponse({ error: 'Attachment not found' }, 404);
740
- return this.jsonResponse({ attachment });
741
- }
742
- if (method === 'DELETE') {
743
- const deleted = await engine.deleteAttachment(attachmentId);
744
- if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
745
- return this.jsonResponse({ success: true });
746
- }
747
- }
748
- }
749
-
750
- // Time Tracking API
751
- if (segments[0] === 'time-entries') {
752
- if (method === 'GET') {
753
- const taskId = url.searchParams.get('taskId');
754
- if (!taskId) return this.jsonResponse({ error: 'taskId parameter required' }, 400);
755
- const entries = await engine.getTimeEntries(taskId);
756
- return this.jsonResponse({ timeEntries: entries });
757
- }
758
- if (method === 'POST') {
759
- const body = await this.readBody(request, schemas.LogTimeSchema);
760
- const entry = await engine.logTime(body);
761
- return this.jsonResponse({ timeEntry: entry }, 201);
762
- }
763
- }
764
-
765
- // Workload & Capacity API
766
- if (segments[0] === 'workload') {
767
- if (method === 'GET') {
768
- const projectId = url.searchParams.get('projectId') || undefined;
769
- const startDate = url.searchParams.get('startDate') || undefined;
770
- const endDate = url.searchParams.get('endDate') || undefined;
771
- const interval = (url.searchParams.get('interval') || undefined) as any;
772
- const groupBy = (url.searchParams.get('groupBy') || undefined) as any;
773
- const metric = (url.searchParams.get('metric') || undefined) as any;
774
- const defaultWeeklyCapacityHours = url.searchParams.get('defaultWeeklyCapacityHours')
775
- ? parseFloat(url.searchParams.get('defaultWeeklyCapacityHours')!)
776
- : undefined;
777
-
778
- const workload = await engine.getWorkloadDistribution(projectId, {
779
- startDate,
780
- endDate,
781
- interval,
782
- groupBy,
783
- metric,
784
- defaultWeeklyCapacityHours
785
- });
786
- return this.jsonResponse({ workload });
787
- }
788
- }
789
-
790
- // Agent Status / Telemetry API
791
- if (segments[0] === 'status') {
792
- if (method === 'POST') {
793
- const body = await this.readJson(request);
794
- const status = typeof body.status === 'string' ? body.status : 'active';
795
- if (engine.events) {
796
- engine.events.publish({
797
- type: 'agent.status_updated' as any,
798
- aggregateId: body.taskId || body.projectId || 'system',
799
- payload: {
800
- status,
801
- taskId: body.taskId,
802
- projectId: body.projectId,
803
- details: body.details,
804
- isEngaged: body.isEngaged ?? true,
805
- timestamp: Date.now()
806
- }
807
- } as any);
808
- }
809
- return this.jsonResponse({
810
- success: true,
811
- status,
812
- timestamp: Date.now()
813
- });
814
- }
815
- }
816
-
817
- return this.jsonResponse({ error: `Route not found: ${method} ${pathname}` }, 404);
818
- }
819
-
820
- /**
821
- * Reads and validates a JSON body. `overrides` (e.g. a `taskId` from the URL) are applied before
822
- * validation so path parameters always win over body fields.
823
- */
824
- private async readBody<S extends { safeParse(data: unknown): unknown }>(
825
- request: Request,
826
- schema: S,
827
- overrides: Record<string, unknown> = {}
828
- ): Promise<S extends { _output: infer O } ? O : never> {
829
- const raw = await this.readJson(request);
830
- const body = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
831
- return schemas.parsePayload(schema as any, { ...body, ...overrides });
832
- }
833
-
834
- /** The API root as seen by this request, e.g. `https://host/api/critical-path`. */
835
- private openApiServerUrl(url: URL, subpath: string): string {
836
- const root = url.pathname.slice(0, url.pathname.length - subpath.length).replace(/\/+$/, '');
837
- return `${url.origin}${root}`;
838
- }
839
-
840
- private async readJson(request: Request): Promise<any> {
841
- try {
842
- return await request.json();
843
- } catch {
844
- throw new BadRequestError('Request body must be valid JSON.');
845
- }
846
- }
847
-
848
- private async errorResponse(err: unknown, request: Request): Promise<Response> {
849
- const name = err && typeof err === 'object' && 'name' in err ? (err as Error).name : undefined;
850
- const e = err as Record<string, any>;
851
-
852
- switch (name) {
853
- case 'BadRequestError':
854
- case 'AttachmentValidationError':
855
- return this.jsonResponse({ error: e.message }, 400);
856
- case 'ValidationError':
857
- return this.jsonResponse(e.issues ? { error: e.message, issues: e.issues } : { error: e.message }, 400);
858
- case 'WorkflowValidationError':
859
- return this.jsonResponse({ error: e.message, fromStatus: e.fromStatus, toStatus: e.toStatus }, 400);
860
- case 'CustomFieldValidationError':
861
- return this.jsonResponse({ error: e.message, fieldKey: e.fieldKey }, 400);
862
- case 'CircularDependencyError':
863
- return this.jsonResponse({ error: e.message, cyclePath: e.cyclePath }, 409);
864
- case 'NotFoundError':
865
- return this.jsonResponse({ error: e.message }, 404);
866
- case 'ForbiddenError':
867
- return this.jsonResponse({ error: e.message }, 403);
868
- }
869
-
870
- // Unexpected errors may carry internals (SQL, URLs, config details), so they are only
871
- // exposed to callers when explicitly enabled.
872
- if (this.onError) {
873
- try {
874
- const custom = await this.onError(err, request);
875
- if (custom instanceof Response) return custom;
876
- } catch (hookErr) {
877
- console.error('[CriticalPathRouter] onError hook threw:', hookErr);
878
- }
879
- } else {
880
- console.error('[CriticalPathRouter] Unhandled error:', err);
881
- }
882
-
883
- const message = this.exposeErrors && err instanceof Error ? err.message : 'Internal Server Error';
884
- return this.jsonResponse({ error: message }, 500);
885
- }
886
-
887
- private jsonResponse(data: unknown, status = 200): Response {
888
- return new Response(JSON.stringify(data), {
889
- status,
890
- headers: {
891
- 'Content-Type': 'application/json'
892
- }
893
- });
894
- }
895
- }