@critical-path/server 0.13.5 → 0.14.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,14 +1,49 @@
1
1
  import { CriticalPathEngine, type CriticalPathConfig } from '@critical-path/core';
2
2
 
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
+ };
8
+
9
+ class BadRequestError extends Error {
10
+ constructor(message: string) {
11
+ super(message);
12
+ this.name = 'BadRequestError';
13
+ }
14
+ }
15
+
16
+ export interface CriticalPathRouterOptions {
17
+ /**
18
+ * Called for unexpected errors (those that would produce a 500), e.g. to report to Sentry.
19
+ * Return a `Response` to replace the default 500 response. When omitted, errors are logged
20
+ * with `console.error`.
21
+ */
22
+ onError?: (error: unknown, request: Request) => void | Response | Promise<void | Response>;
23
+ /**
24
+ * Include the real error message in 500 responses. Defaults to `true` only when
25
+ * `NODE_ENV === 'development'`, so production deployments never leak internals.
26
+ */
27
+ exposeErrors?: boolean;
28
+ }
29
+
30
+ function isDevelopment(): boolean {
31
+ return (globalThis as any).process?.env?.NODE_ENV === 'development';
32
+ }
33
+
3
34
  export class CriticalPathRouter {
4
35
  public engine: CriticalPathEngine;
36
+ private readonly onError?: CriticalPathRouterOptions['onError'];
37
+ private readonly exposeErrors: boolean;
5
38
 
6
- constructor(configOrEngine?: CriticalPathConfig | CriticalPathEngine) {
39
+ constructor(configOrEngine?: CriticalPathConfig | CriticalPathEngine, options: CriticalPathRouterOptions = {}) {
7
40
  if (configOrEngine instanceof CriticalPathEngine) {
8
41
  this.engine = configOrEngine;
9
42
  } else {
10
43
  this.engine = new CriticalPathEngine(configOrEngine);
11
44
  }
45
+ this.onError = options.onError;
46
+ this.exposeErrors = options.exposeErrors ?? isDevelopment();
12
47
  }
13
48
 
14
49
  async handleRequest(request: Request): Promise<Response> {
@@ -20,6 +55,11 @@ export class CriticalPathRouter {
20
55
  const subpath = pathname.replace(/^.*?\/critical-path\/?/, '').replace(/^\/+/, '');
21
56
  const segments = subpath.split('/').filter(Boolean);
22
57
 
58
+ // CORS preflight
59
+ if (method === 'OPTIONS') {
60
+ return new Response(null, { status: 204, headers: CORS_HEADERS });
61
+ }
62
+
23
63
  try {
24
64
  // Workflows API
25
65
  if (segments[0] === 'workflows') {
@@ -30,7 +70,7 @@ export class CriticalPathRouter {
30
70
  return this.jsonResponse({ workflows });
31
71
  }
32
72
  if (method === 'POST') {
33
- const body = await request.json();
73
+ const body = await this.readJson(request);
34
74
  const workflow = await this.engine.createWorkflow(body);
35
75
  return this.jsonResponse({ workflow }, 201);
36
76
  }
@@ -41,14 +81,15 @@ export class CriticalPathRouter {
41
81
  return this.jsonResponse({ workflow });
42
82
  }
43
83
  if (method === 'PATCH' || method === 'PUT') {
44
- const body = await request.json();
84
+ const body = await this.readJson(request);
45
85
  const updated = await this.engine.updateWorkflow(workflowId, body);
46
86
  if (!updated) return this.jsonResponse({ error: 'Workflow not found' }, 404);
47
87
  return this.jsonResponse({ workflow: updated });
48
88
  }
49
89
  if (method === 'DELETE') {
50
90
  const deleted = await this.engine.deleteWorkflow(workflowId);
51
- return this.jsonResponse({ success: deleted });
91
+ if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
92
+ return this.jsonResponse({ success: true });
52
93
  }
53
94
  }
54
95
  }
@@ -63,7 +104,7 @@ export class CriticalPathRouter {
63
104
  return this.jsonResponse({ projects });
64
105
  }
65
106
  if (method === 'POST') {
66
- const body = await request.json();
107
+ const body = await this.readJson(request);
67
108
  const project = await this.engine.createProject(body);
68
109
  return this.jsonResponse({ project }, 201);
69
110
  }
@@ -107,9 +148,16 @@ export class CriticalPathRouter {
107
148
  if (!project) return this.jsonResponse({ error: 'Project not found' }, 404);
108
149
  return this.jsonResponse({ project });
109
150
  }
151
+ 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);
154
+ if (!project) return this.jsonResponse({ error: 'Project not found' }, 404);
155
+ return this.jsonResponse({ project });
156
+ }
110
157
  if (method === 'DELETE') {
111
- const deleted = await this.engine.store.deleteProject(projectId);
112
- return this.jsonResponse({ success: deleted });
158
+ const deleted = await this.engine.deleteProject(projectId);
159
+ if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
160
+ return this.jsonResponse({ success: true });
113
161
  }
114
162
  }
115
163
  }
@@ -126,7 +174,7 @@ export class CriticalPathRouter {
126
174
  return this.jsonResponse({ tasks });
127
175
  }
128
176
  if (method === 'POST') {
129
- const body = await request.json();
177
+ const body = await this.readJson(request);
130
178
  const task = await this.engine.createTask(body);
131
179
  return this.jsonResponse({ task }, 201);
132
180
  }
@@ -136,7 +184,7 @@ export class CriticalPathRouter {
136
184
  return this.jsonResponse({ comments });
137
185
  }
138
186
  if (method === 'POST') {
139
- const body = await request.json();
187
+ const body = await this.readJson(request);
140
188
  const comment = await this.engine.addComment({ ...body, taskId });
141
189
  return this.jsonResponse({ comment }, 201);
142
190
  }
@@ -146,7 +194,7 @@ export class CriticalPathRouter {
146
194
  return this.jsonResponse({ attachments });
147
195
  }
148
196
  if (method === 'POST') {
149
- const body = await request.json();
197
+ const body = await this.readJson(request);
150
198
  const attachment = await this.engine.createAttachment({ ...body, taskId });
151
199
  return this.jsonResponse({ attachment }, 201);
152
200
  }
@@ -156,8 +204,11 @@ export class CriticalPathRouter {
156
204
  return this.jsonResponse({ graph });
157
205
  }
158
206
  if (method === 'POST') {
159
- const body = await request.json();
160
- const dep = await this.engine.store.addDependency({
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({
161
212
  taskId,
162
213
  dependsOnTaskId: body.dependsOnTaskId,
163
214
  type: body.type || 'blocking'
@@ -201,14 +252,15 @@ export class CriticalPathRouter {
201
252
  return this.jsonResponse({ task });
202
253
  }
203
254
  if (method === 'PATCH' || method === 'PUT') {
204
- const body = await request.json();
255
+ const body = await this.readJson(request);
205
256
  const updated = await this.engine.updateTask(taskId, body);
206
257
  if (!updated) return this.jsonResponse({ error: 'Task not found' }, 404);
207
258
  return this.jsonResponse({ task: updated });
208
259
  }
209
260
  if (method === 'DELETE') {
210
261
  const deleted = await this.engine.deleteTask(taskId);
211
- return this.jsonResponse({ success: deleted });
262
+ if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
263
+ return this.jsonResponse({ success: true });
212
264
  }
213
265
  }
214
266
  }
@@ -222,7 +274,7 @@ export class CriticalPathRouter {
222
274
  return this.jsonResponse({ teams });
223
275
  }
224
276
  if (method === 'POST') {
225
- const body = await request.json();
277
+ const body = await this.readJson(request);
226
278
  const team = await this.engine.createTeam(body);
227
279
  return this.jsonResponse({ team }, 201);
228
280
  }
@@ -233,14 +285,15 @@ export class CriticalPathRouter {
233
285
  return this.jsonResponse({ team });
234
286
  }
235
287
  if (method === 'PATCH' || method === 'PUT') {
236
- const body = await request.json();
288
+ const body = await this.readJson(request);
237
289
  const updated = await this.engine.updateTeam(teamId, body);
238
290
  if (!updated) return this.jsonResponse({ error: 'Team not found' }, 404);
239
291
  return this.jsonResponse({ team: updated });
240
292
  }
241
293
  if (method === 'DELETE') {
242
294
  const deleted = await this.engine.deleteTeam(teamId);
243
- return this.jsonResponse({ success: deleted });
295
+ if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
296
+ return this.jsonResponse({ success: true });
244
297
  }
245
298
  }
246
299
  }
@@ -256,7 +309,7 @@ export class CriticalPathRouter {
256
309
  return this.jsonResponse({ containers });
257
310
  }
258
311
  if (method === 'POST') {
259
- const body = await request.json();
312
+ const body = await this.readJson(request);
260
313
  const container = await this.engine.createContainer(body);
261
314
  return this.jsonResponse({ container }, 201);
262
315
  }
@@ -267,14 +320,15 @@ export class CriticalPathRouter {
267
320
  return this.jsonResponse({ container });
268
321
  }
269
322
  if (method === 'PATCH' || method === 'PUT') {
270
- const body = await request.json();
323
+ const body = await this.readJson(request);
271
324
  const updated = await this.engine.updateContainer(containerId, body);
272
325
  if (!updated) return this.jsonResponse({ error: 'Container not found' }, 404);
273
326
  return this.jsonResponse({ container: updated });
274
327
  }
275
328
  if (method === 'DELETE') {
276
329
  const deleted = await this.engine.deleteContainer(containerId);
277
- return this.jsonResponse({ success: deleted });
330
+ if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
331
+ return this.jsonResponse({ success: true });
278
332
  }
279
333
  }
280
334
  }
@@ -290,7 +344,7 @@ export class CriticalPathRouter {
290
344
  return this.jsonResponse({ deliverables });
291
345
  }
292
346
  if (method === 'POST') {
293
- const body = await request.json();
347
+ const body = await this.readJson(request);
294
348
  const deliverable = await this.engine.createDeliverable(body);
295
349
  return this.jsonResponse({ deliverable }, 201);
296
350
  }
@@ -307,14 +361,15 @@ export class CriticalPathRouter {
307
361
  return this.jsonResponse({ deliverable });
308
362
  }
309
363
  if (method === 'PATCH' || method === 'PUT') {
310
- const body = await request.json();
364
+ const body = await this.readJson(request);
311
365
  const updated = await this.engine.updateDeliverable(deliverableId, body);
312
366
  if (!updated) return this.jsonResponse({ error: 'Deliverable not found' }, 404);
313
367
  return this.jsonResponse({ deliverable: updated });
314
368
  }
315
369
  if (method === 'DELETE') {
316
370
  const deleted = await this.engine.deleteDeliverable(deliverableId);
317
- return this.jsonResponse({ success: deleted });
371
+ if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
372
+ return this.jsonResponse({ success: true });
318
373
  }
319
374
  }
320
375
  }
@@ -330,7 +385,7 @@ export class CriticalPathRouter {
330
385
  return this.jsonResponse({ iterations });
331
386
  }
332
387
  if (method === 'POST') {
333
- const body = await request.json();
388
+ const body = await this.readJson(request);
334
389
  const iteration = await this.engine.createIteration(body);
335
390
  return this.jsonResponse({ iteration }, 201);
336
391
  }
@@ -341,14 +396,15 @@ export class CriticalPathRouter {
341
396
  return this.jsonResponse({ iteration });
342
397
  }
343
398
  if (method === 'PATCH' || method === 'PUT') {
344
- const body = await request.json();
399
+ const body = await this.readJson(request);
345
400
  const updated = await this.engine.updateIteration(iterationId, body);
346
401
  if (!updated) return this.jsonResponse({ error: 'Iteration not found' }, 404);
347
402
  return this.jsonResponse({ iteration: updated });
348
403
  }
349
404
  if (method === 'DELETE') {
350
405
  const deleted = await this.engine.deleteIteration(iterationId);
351
- return this.jsonResponse({ success: deleted });
406
+ if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
407
+ return this.jsonResponse({ success: true });
352
408
  }
353
409
  }
354
410
  }
@@ -369,7 +425,7 @@ export class CriticalPathRouter {
369
425
  const subResource = segments[2];
370
426
  if (commentId && subResource === 'reactions') {
371
427
  if (method === 'POST') {
372
- const body = await request.json();
428
+ const body = await this.readJson(request);
373
429
  if (!body.emoji || !body.userId) {
374
430
  return this.jsonResponse({ error: 'emoji and userId are required' }, 400);
375
431
  }
@@ -380,7 +436,7 @@ export class CriticalPathRouter {
380
436
  if (method === 'DELETE') {
381
437
  let body: any = {};
382
438
  try {
383
- body = await request.json();
439
+ body = await this.readJson(request);
384
440
  } catch {
385
441
  // Body may be empty on DELETE, fallback to searchParams
386
442
  }
@@ -401,7 +457,7 @@ export class CriticalPathRouter {
401
457
  return this.jsonResponse({ comments });
402
458
  }
403
459
  if (method === 'POST') {
404
- const body = await request.json();
460
+ const body = await this.readJson(request);
405
461
  const comment = await this.engine.addComment(body);
406
462
  return this.jsonResponse({ comment }, 201);
407
463
  }
@@ -412,14 +468,15 @@ export class CriticalPathRouter {
412
468
  return this.jsonResponse({ comment });
413
469
  }
414
470
  if (method === 'PATCH' || method === 'PUT') {
415
- const body = await request.json();
471
+ const body = await this.readJson(request);
416
472
  const updated = await this.engine.updateComment(commentId, body);
417
473
  if (!updated) return this.jsonResponse({ error: 'Comment not found' }, 404);
418
474
  return this.jsonResponse({ comment: updated });
419
475
  }
420
476
  if (method === 'DELETE') {
421
477
  const deleted = await this.engine.deleteComment(commentId);
422
- return this.jsonResponse({ success: deleted });
478
+ if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
479
+ return this.jsonResponse({ success: true });
423
480
  }
424
481
  }
425
482
  }
@@ -429,13 +486,13 @@ export class CriticalPathRouter {
429
486
  const attachmentId = segments[1];
430
487
  if (attachmentId === 'presign') {
431
488
  if (method === 'POST') {
432
- const body = await request.json();
489
+ const body = await this.readJson(request);
433
490
  const presigned = await this.engine.getPresignedAttachmentUploadUrl(body);
434
491
  return this.jsonResponse({ presigned });
435
492
  }
436
493
  } else if (attachmentId === 'upload') {
437
494
  if (method === 'POST') {
438
- const body = await request.json();
495
+ const body = await this.readJson(request);
439
496
  const attachment = await this.engine.uploadAttachmentFile(body);
440
497
  return this.jsonResponse({ attachment }, 201);
441
498
  }
@@ -448,7 +505,7 @@ export class CriticalPathRouter {
448
505
  return this.jsonResponse({ attachments });
449
506
  }
450
507
  if (method === 'POST') {
451
- const body = await request.json();
508
+ const body = await this.readJson(request);
452
509
  const attachment = await this.engine.createAttachment(body);
453
510
  return this.jsonResponse({ attachment }, 201);
454
511
  }
@@ -460,7 +517,8 @@ export class CriticalPathRouter {
460
517
  }
461
518
  if (method === 'DELETE') {
462
519
  const deleted = await this.engine.deleteAttachment(attachmentId);
463
- return this.jsonResponse({ success: deleted });
520
+ if (!deleted) return this.jsonResponse({ error: 'Not found' }, 404);
521
+ return this.jsonResponse({ success: true });
464
522
  }
465
523
  }
466
524
  }
@@ -474,8 +532,8 @@ export class CriticalPathRouter {
474
532
  return this.jsonResponse({ timeEntries: entries });
475
533
  }
476
534
  if (method === 'POST') {
477
- const body = await request.json();
478
- const entry = await this.engine.store.logTime(body);
535
+ const body = await this.readJson(request);
536
+ const entry = await this.engine.logTime(body);
479
537
  return this.jsonResponse({ timeEntry: entry }, 201);
480
538
  }
481
539
  }
@@ -508,7 +566,7 @@ export class CriticalPathRouter {
508
566
  // Agent Status / Telemetry API
509
567
  if (segments[0] === 'status') {
510
568
  if (method === 'POST') {
511
- const body = await request.json();
569
+ const body = await this.readJson(request);
512
570
  const status = typeof body.status === 'string' ? body.status : 'active';
513
571
  if (this.engine.events) {
514
572
  this.engine.events.publish({
@@ -534,19 +592,52 @@ export class CriticalPathRouter {
534
592
 
535
593
  return this.jsonResponse({ error: `Route not found: ${method} ${pathname}` }, 404);
536
594
  } catch (err: unknown) {
537
- if (err && typeof err === 'object' && 'name' in err) {
538
- if (err.name === 'WorkflowValidationError') {
539
- const wfErr = err as unknown as { message: string; fromStatus?: string; toStatus?: string };
540
- return this.jsonResponse({ error: wfErr.message, fromStatus: wfErr.fromStatus, toStatus: wfErr.toStatus }, 400);
541
- }
542
- if (err.name === 'AttachmentValidationError') {
543
- const attErr = err as unknown as { message: string };
544
- return this.jsonResponse({ error: attErr.message }, 400);
545
- }
595
+ return this.errorResponse(err, request);
596
+ }
597
+ }
598
+
599
+ private async readJson(request: Request): Promise<any> {
600
+ try {
601
+ return await request.json();
602
+ } catch {
603
+ throw new BadRequestError('Request body must be valid JSON.');
604
+ }
605
+ }
606
+
607
+ private async errorResponse(err: unknown, request: Request): Promise<Response> {
608
+ const name = err && typeof err === 'object' && 'name' in err ? (err as Error).name : undefined;
609
+ const e = err as Record<string, any>;
610
+
611
+ switch (name) {
612
+ case 'BadRequestError':
613
+ case 'ValidationError':
614
+ case 'AttachmentValidationError':
615
+ return this.jsonResponse({ error: e.message }, 400);
616
+ case 'WorkflowValidationError':
617
+ return this.jsonResponse({ error: e.message, fromStatus: e.fromStatus, toStatus: e.toStatus }, 400);
618
+ case 'CustomFieldValidationError':
619
+ return this.jsonResponse({ error: e.message, fieldKey: e.fieldKey }, 400);
620
+ case 'CircularDependencyError':
621
+ return this.jsonResponse({ error: e.message, cyclePath: e.cyclePath }, 409);
622
+ case 'NotFoundError':
623
+ return this.jsonResponse({ error: e.message }, 404);
624
+ }
625
+
626
+ // Unexpected errors may carry internals (SQL, URLs, config details), so they are only
627
+ // exposed to callers when explicitly enabled.
628
+ if (this.onError) {
629
+ try {
630
+ const custom = await this.onError(err, request);
631
+ if (custom instanceof Response) return custom;
632
+ } catch (hookErr) {
633
+ console.error('[CriticalPathRouter] onError hook threw:', hookErr);
546
634
  }
547
- const message = err instanceof Error ? err.message : 'Internal Server Error';
548
- return this.jsonResponse({ error: message }, 500);
635
+ } else {
636
+ console.error('[CriticalPathRouter] Unhandled error:', err);
549
637
  }
638
+
639
+ const message = this.exposeErrors && err instanceof Error ? err.message : 'Internal Server Error';
640
+ return this.jsonResponse({ error: message }, 500);
550
641
  }
551
642
 
552
643
  private jsonResponse(data: unknown, status = 200): Response {
@@ -554,9 +645,7 @@ export class CriticalPathRouter {
554
645
  status,
555
646
  headers: {
556
647
  'Content-Type': 'application/json',
557
- 'Access-Control-Allow-Origin': '*',
558
- 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
559
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization'
648
+ ...CORS_HEADERS
560
649
  }
561
650
  });
562
651
  }