@critical-path/server 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/router.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { CriticalPathEngine, type AuthorType, type CriticalPathConfig } from '@critical-path/core';
2
+ import * as schemas from '@critical-path/core/schemas';
3
+ import { buildOpenApiDocument } from './openapi.js';
2
4
 
3
5
  const CORS_ALLOW_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
4
6
  const DEFAULT_CORS_ALLOW_HEADERS = ['Content-Type', 'Authorization'];
@@ -173,6 +175,11 @@ export class CriticalPathRouter {
173
175
  : this.engine;
174
176
 
175
177
  try {
178
+ // OpenAPI contract (behind the same auth as every other route)
179
+ if (segments[0] === 'openapi.json' && segments.length === 1 && method === 'GET') {
180
+ return this.jsonResponse(buildOpenApiDocument({ serverUrl: this.openApiServerUrl(url, subpath) }));
181
+ }
182
+
176
183
  // Workflows API
177
184
  if (segments[0] === 'workflows') {
178
185
  const workflowId = segments[1];
@@ -182,7 +189,7 @@ export class CriticalPathRouter {
182
189
  return this.jsonResponse({ workflows });
183
190
  }
184
191
  if (method === 'POST') {
185
- const body = await this.readJson(request);
192
+ const body = await this.readBody(request, schemas.CreateWorkflowSchema);
186
193
  const workflow = await engine.createWorkflow(body);
187
194
  return this.jsonResponse({ workflow }, 201);
188
195
  }
@@ -193,7 +200,7 @@ export class CriticalPathRouter {
193
200
  return this.jsonResponse({ workflow });
194
201
  }
195
202
  if (method === 'PATCH' || method === 'PUT') {
196
- const body = await this.readJson(request);
203
+ const body = await this.readBody(request, schemas.UpdateWorkflowSchema);
197
204
  const updated = await engine.updateWorkflow(workflowId, body);
198
205
  if (!updated) return this.jsonResponse({ error: 'Workflow not found' }, 404);
199
206
  return this.jsonResponse({ workflow: updated });
@@ -216,7 +223,7 @@ export class CriticalPathRouter {
216
223
  return this.jsonResponse({ projects });
217
224
  }
218
225
  if (method === 'POST') {
219
- const body = await this.readJson(request);
226
+ const body = await this.readBody(request, schemas.CreateProjectSchema);
220
227
  const project = await engine.createProject(body);
221
228
  return this.jsonResponse({ project }, 201);
222
229
  }
@@ -261,7 +268,7 @@ export class CriticalPathRouter {
261
268
  return this.jsonResponse({ project });
262
269
  }
263
270
  if (method === 'PATCH' || method === 'PUT') {
264
- const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...updates } = await this.readJson(request);
271
+ const updates = await this.readBody(request, schemas.UpdateProjectSchema);
265
272
  const project = await engine.updateProject(projectId, updates);
266
273
  if (!project) return this.jsonResponse({ error: 'Project not found' }, 404);
267
274
  return this.jsonResponse({ project });
@@ -286,7 +293,7 @@ export class CriticalPathRouter {
286
293
  return this.jsonResponse({ tasks });
287
294
  }
288
295
  if (method === 'POST') {
289
- const body = await this.readJson(request);
296
+ const body = await this.readBody(request, schemas.CreateTaskSchema);
290
297
  const task = await engine.createTask(body);
291
298
  return this.jsonResponse({ task }, 201);
292
299
  }
@@ -296,8 +303,8 @@ export class CriticalPathRouter {
296
303
  return this.jsonResponse({ comments });
297
304
  }
298
305
  if (method === 'POST') {
299
- const body = await this.readJson(request);
300
- const comment = await engine.addComment({ ...body, taskId });
306
+ const body = await this.readBody(request, schemas.CreateCommentSchema, { taskId });
307
+ const comment = await engine.addComment({ ...body, authorId: this.requireIdentity(context, body.authorId, 'authorId') });
301
308
  return this.jsonResponse({ comment }, 201);
302
309
  }
303
310
  } else if (subResource === 'attachments') {
@@ -306,8 +313,8 @@ export class CriticalPathRouter {
306
313
  return this.jsonResponse({ attachments });
307
314
  }
308
315
  if (method === 'POST') {
309
- const body = await this.readJson(request);
310
- const attachment = await engine.createAttachment({ ...body, taskId });
316
+ const body = await this.readBody(request, schemas.CreateAttachmentSchema, { taskId });
317
+ const attachment = await engine.createAttachment({ ...body, uploaderId: this.requireIdentity(context, body.uploaderId, 'uploaderId') });
311
318
  return this.jsonResponse({ attachment }, 201);
312
319
  }
313
320
  } else if (subResource === 'dependencies') {
@@ -316,10 +323,7 @@ export class CriticalPathRouter {
316
323
  return this.jsonResponse({ graph });
317
324
  }
318
325
  if (method === 'POST') {
319
- const body = await this.readJson(request);
320
- if (typeof body.dependsOnTaskId !== 'string' || !body.dependsOnTaskId) {
321
- return this.jsonResponse({ error: 'dependsOnTaskId is required' }, 400);
322
- }
326
+ const body = await this.readBody(request, schemas.CreateDependencySchema);
323
327
  const dep = await engine.addDependency({
324
328
  taskId,
325
329
  dependsOnTaskId: body.dependsOnTaskId,
@@ -364,7 +368,7 @@ export class CriticalPathRouter {
364
368
  return this.jsonResponse({ task });
365
369
  }
366
370
  if (method === 'PATCH' || method === 'PUT') {
367
- const body = await this.readJson(request);
371
+ const body = await this.readBody(request, schemas.UpdateTaskSchema);
368
372
  const updated = await engine.updateTask(taskId, body);
369
373
  if (!updated) return this.jsonResponse({ error: 'Task not found' }, 404);
370
374
  return this.jsonResponse({ task: updated });
@@ -386,7 +390,7 @@ export class CriticalPathRouter {
386
390
  return this.jsonResponse({ teams });
387
391
  }
388
392
  if (method === 'POST') {
389
- const body = await this.readJson(request);
393
+ const body = await this.readBody(request, schemas.CreateTeamSchema);
390
394
  const team = await engine.createTeam(body);
391
395
  return this.jsonResponse({ team }, 201);
392
396
  }
@@ -397,7 +401,7 @@ export class CriticalPathRouter {
397
401
  return this.jsonResponse({ team });
398
402
  }
399
403
  if (method === 'PATCH' || method === 'PUT') {
400
- const body = await this.readJson(request);
404
+ const body = await this.readBody(request, schemas.UpdateTeamSchema);
401
405
  const updated = await engine.updateTeam(teamId, body);
402
406
  if (!updated) return this.jsonResponse({ error: 'Team not found' }, 404);
403
407
  return this.jsonResponse({ team: updated });
@@ -421,7 +425,7 @@ export class CriticalPathRouter {
421
425
  return this.jsonResponse({ containers });
422
426
  }
423
427
  if (method === 'POST') {
424
- const body = await this.readJson(request);
428
+ const body = await this.readBody(request, schemas.CreateContainerSchema);
425
429
  const container = await engine.createContainer(body);
426
430
  return this.jsonResponse({ container }, 201);
427
431
  }
@@ -432,7 +436,7 @@ export class CriticalPathRouter {
432
436
  return this.jsonResponse({ container });
433
437
  }
434
438
  if (method === 'PATCH' || method === 'PUT') {
435
- const body = await this.readJson(request);
439
+ const body = await this.readBody(request, schemas.UpdateContainerSchema);
436
440
  const updated = await engine.updateContainer(containerId, body);
437
441
  if (!updated) return this.jsonResponse({ error: 'Container not found' }, 404);
438
442
  return this.jsonResponse({ container: updated });
@@ -456,7 +460,7 @@ export class CriticalPathRouter {
456
460
  return this.jsonResponse({ deliverables });
457
461
  }
458
462
  if (method === 'POST') {
459
- const body = await this.readJson(request);
463
+ const body = await this.readBody(request, schemas.CreateDeliverableSchema);
460
464
  const deliverable = await engine.createDeliverable(body);
461
465
  return this.jsonResponse({ deliverable }, 201);
462
466
  }
@@ -473,7 +477,7 @@ export class CriticalPathRouter {
473
477
  return this.jsonResponse({ deliverable });
474
478
  }
475
479
  if (method === 'PATCH' || method === 'PUT') {
476
- const body = await this.readJson(request);
480
+ const body = await this.readBody(request, schemas.UpdateDeliverableSchema);
477
481
  const updated = await engine.updateDeliverable(deliverableId, body);
478
482
  if (!updated) return this.jsonResponse({ error: 'Deliverable not found' }, 404);
479
483
  return this.jsonResponse({ deliverable: updated });
@@ -497,7 +501,7 @@ export class CriticalPathRouter {
497
501
  return this.jsonResponse({ iterations });
498
502
  }
499
503
  if (method === 'POST') {
500
- const body = await this.readJson(request);
504
+ const body = await this.readBody(request, schemas.CreateIterationSchema);
501
505
  const iteration = await engine.createIteration(body);
502
506
  return this.jsonResponse({ iteration }, 201);
503
507
  }
@@ -508,7 +512,7 @@ export class CriticalPathRouter {
508
512
  return this.jsonResponse({ iteration });
509
513
  }
510
514
  if (method === 'PATCH' || method === 'PUT') {
511
- const body = await this.readJson(request);
515
+ const body = await this.readBody(request, schemas.UpdateIterationSchema);
512
516
  const updated = await engine.updateIteration(iterationId, body);
513
517
  if (!updated) return this.jsonResponse({ error: 'Iteration not found' }, 404);
514
518
  return this.jsonResponse({ iteration: updated });
@@ -537,11 +541,8 @@ export class CriticalPathRouter {
537
541
  const subResource = segments[2];
538
542
  if (commentId && subResource === 'reactions') {
539
543
  if (method === 'POST') {
540
- const body = await this.readJson(request);
541
- const userId = context?.userId ?? body.userId;
542
- if (!body.emoji || !userId) {
543
- return this.jsonResponse({ error: 'emoji and userId are required' }, 400);
544
- }
544
+ const body = await this.readBody(request, schemas.CommentReactionSchema);
545
+ const userId = this.requireIdentity(context, body.userId, 'userId');
545
546
  const comment = await engine.addCommentReaction(commentId, { emoji: body.emoji, userId });
546
547
  if (!comment) return this.jsonResponse({ error: 'Comment not found' }, 404);
547
548
  return this.jsonResponse({ comment }, 200);
@@ -570,8 +571,8 @@ export class CriticalPathRouter {
570
571
  return this.jsonResponse({ comments });
571
572
  }
572
573
  if (method === 'POST') {
573
- const body = await this.readJson(request);
574
- const comment = await engine.addComment(body);
574
+ const body = await this.readBody(request, schemas.CreateCommentSchema);
575
+ const comment = await engine.addComment({ ...body, authorId: this.requireIdentity(context, body.authorId, 'authorId') });
575
576
  return this.jsonResponse({ comment }, 201);
576
577
  }
577
578
  } else {
@@ -581,7 +582,7 @@ export class CriticalPathRouter {
581
582
  return this.jsonResponse({ comment });
582
583
  }
583
584
  if (method === 'PATCH' || method === 'PUT') {
584
- const body = await this.readJson(request);
585
+ const body = await this.readBody(request, schemas.UpdateCommentSchema);
585
586
  const updated = await engine.updateComment(commentId, body);
586
587
  if (!updated) return this.jsonResponse({ error: 'Comment not found' }, 404);
587
588
  return this.jsonResponse({ comment: updated });
@@ -599,14 +600,14 @@ export class CriticalPathRouter {
599
600
  const attachmentId = segments[1];
600
601
  if (attachmentId === 'presign') {
601
602
  if (method === 'POST') {
602
- const body = await this.readJson(request);
603
+ const body = await this.readBody(request, schemas.PresignAttachmentSchema);
603
604
  const presigned = await engine.getPresignedAttachmentUploadUrl(body);
604
605
  return this.jsonResponse({ presigned });
605
606
  }
606
607
  } else if (attachmentId === 'upload') {
607
608
  if (method === 'POST') {
608
- const body = await this.readJson(request);
609
- const attachment = await engine.uploadAttachmentFile(body);
609
+ const body = await this.readBody(request, schemas.UploadAttachmentSchema);
610
+ const attachment = await engine.uploadAttachmentFile({ ...body, uploaderId: this.requireIdentity(context, body.uploaderId, 'uploaderId') });
610
611
  return this.jsonResponse({ attachment }, 201);
611
612
  }
612
613
  } else if (!attachmentId) {
@@ -618,8 +619,8 @@ export class CriticalPathRouter {
618
619
  return this.jsonResponse({ attachments });
619
620
  }
620
621
  if (method === 'POST') {
621
- const body = await this.readJson(request);
622
- const attachment = await engine.createAttachment(body);
622
+ const body = await this.readBody(request, schemas.CreateAttachmentSchema);
623
+ const attachment = await engine.createAttachment({ ...body, uploaderId: this.requireIdentity(context, body.uploaderId, 'uploaderId') });
623
624
  return this.jsonResponse({ attachment }, 201);
624
625
  }
625
626
  } else {
@@ -645,7 +646,7 @@ export class CriticalPathRouter {
645
646
  return this.jsonResponse({ timeEntries: entries });
646
647
  }
647
648
  if (method === 'POST') {
648
- const body = await this.readJson(request);
649
+ const body = await this.readBody(request, schemas.LogTimeSchema);
649
650
  const entry = await engine.logTime(body);
650
651
  return this.jsonResponse({ timeEntry: entry }, 201);
651
652
  }
@@ -709,6 +710,36 @@ export class CriticalPathRouter {
709
710
  }
710
711
  }
711
712
 
713
+ /**
714
+ * Reads and validates a JSON body. `overrides` (e.g. a `taskId` from the URL) are applied before
715
+ * validation so path parameters always win over body fields.
716
+ */
717
+ private async readBody<S extends { safeParse(data: unknown): unknown }>(
718
+ request: Request,
719
+ schema: S,
720
+ overrides: Record<string, unknown> = {}
721
+ ): Promise<S extends { _output: infer O } ? O : never> {
722
+ const raw = await this.readJson(request);
723
+ const body = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
724
+ return schemas.parsePayload(schema as any, { ...body, ...overrides });
725
+ }
726
+
727
+ /**
728
+ * Returns the identity to record for a write: the resolved caller when there is one, otherwise
729
+ * the value supplied in the body. Throws a 400 when neither is present.
730
+ */
731
+ private requireIdentity(context: RequestContext | null | undefined, claimed: string | undefined, field: string): string {
732
+ const identity = context?.userId ?? claimed;
733
+ if (!identity) throw new BadRequestError(`${field} is required`);
734
+ return identity;
735
+ }
736
+
737
+ /** The API root as seen by this request, e.g. `https://host/api/critical-path`. */
738
+ private openApiServerUrl(url: URL, subpath: string): string {
739
+ const root = url.pathname.slice(0, url.pathname.length - subpath.length).replace(/\/+$/, '');
740
+ return `${url.origin}${root}`;
741
+ }
742
+
712
743
  private async readJson(request: Request): Promise<any> {
713
744
  try {
714
745
  return await request.json();
@@ -723,9 +754,10 @@ export class CriticalPathRouter {
723
754
 
724
755
  switch (name) {
725
756
  case 'BadRequestError':
726
- case 'ValidationError':
727
757
  case 'AttachmentValidationError':
728
758
  return this.jsonResponse({ error: e.message }, 400);
759
+ case 'ValidationError':
760
+ return this.jsonResponse(e.issues ? { error: e.message, issues: e.issues } : { error: e.message }, 400);
729
761
  case 'WorkflowValidationError':
730
762
  return this.jsonResponse({ error: e.message, fromStatus: e.fromStatus, toStatus: e.toStatus }, 400);
731
763
  case 'CustomFieldValidationError':
@@ -1 +1 @@
1
- {"root":["./src/index.test.ts","./src/index.ts","./src/router.ts","./src/adapters/next.ts","./src/adapters/sveltekit.ts","./src/adapters/universal.ts"],"version":"5.9.3"}
1
+ {"root":["./src/index.test.ts","./src/index.ts","./src/openapi.test.ts","./src/openapi.ts","./src/router.ts","./src/adapters/next.ts","./src/adapters/sveltekit.ts","./src/adapters/universal.ts"],"version":"5.9.3"}