@critical-path/server 0.15.0 → 0.17.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,9 @@
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';
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 });
2
7
 
3
8
  const CORS_ALLOW_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
4
9
  const DEFAULT_CORS_ALLOW_HEADERS = ['Content-Type', 'Authorization'];
@@ -51,8 +56,8 @@ export interface CriticalPathRouterOptions {
51
56
  exposeErrors?: boolean;
52
57
  /**
53
58
  * 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.
59
+ * When it returns a `userId`, all mutations in the request are attributed to that user;
60
+ * otherwise they are attributed to `ANONYMOUS_ACTOR`. Request bodies never carry identity.
56
61
  */
57
62
  getContext?: (request: Request) => RequestContext | null | undefined | Promise<RequestContext | null | undefined>;
58
63
  /** Reject requests with `401` unless `getContext` returns a `userId`. Defaults to `false`. */
@@ -62,7 +67,10 @@ export interface CriticalPathRouterOptions {
62
67
  * When omitted, everything up to the first `/critical-path` segment is stripped.
63
68
  */
64
69
  basePath?: string;
65
- /** CORS policy. Defaults to `{ origins: '*' }`; pass `false` to send no CORS headers. */
70
+ /**
71
+ * CORS policy. Defaults to `false` (no CORS headers, so only same-origin browsers can call the
72
+ * API). Set `{ origins: [...] }` to allow specific sites, or `{ origins: '*' }` for any site.
73
+ */
66
74
  cors?: CorsOptions | false;
67
75
  }
68
76
 
@@ -90,7 +98,7 @@ export class CriticalPathRouter {
90
98
  this.getContext = options.getContext;
91
99
  this.requireAuth = options.requireAuth ?? false;
92
100
  this.basePath = options.basePath ? '/' + options.basePath.replace(/^\/+|\/+$/g, '') : undefined;
93
- this.cors = options.cors === undefined ? { origins: '*' } : options.cors;
101
+ this.cors = options.cors ?? false;
94
102
  if (this.cors && this.cors.origins === '*' && this.cors.credentials) {
95
103
  throw new Error('CORS credentials cannot be combined with origins "*"; list the allowed origins instead.');
96
104
  }
@@ -168,11 +176,19 @@ export class CriticalPathRouter {
168
176
  if (this.requireAuth && !context?.userId) {
169
177
  return this.jsonResponse({ error: 'Authentication required' }, 401);
170
178
  }
171
- const engine = context?.userId
172
- ? this.engine.withActor({ userId: context.userId, username: context.userName, actorType: context.actorType })
173
- : this.engine;
179
+ // Every request runs as an actor, so authorship never comes from request bodies.
180
+ const engine = this.engine.withActor(
181
+ context?.userId
182
+ ? { userId: context.userId, username: context.userName, actorType: context.actorType }
183
+ : ANONYMOUS_ACTOR
184
+ );
174
185
 
175
186
  try {
187
+ // OpenAPI contract (behind the same auth as every other route)
188
+ if (segments[0] === 'openapi.json' && segments.length === 1 && method === 'GET') {
189
+ return this.jsonResponse(buildOpenApiDocument({ serverUrl: this.openApiServerUrl(url, subpath) }));
190
+ }
191
+
176
192
  // Workflows API
177
193
  if (segments[0] === 'workflows') {
178
194
  const workflowId = segments[1];
@@ -182,7 +198,7 @@ export class CriticalPathRouter {
182
198
  return this.jsonResponse({ workflows });
183
199
  }
184
200
  if (method === 'POST') {
185
- const body = await this.readJson(request);
201
+ const body = await this.readBody(request, schemas.CreateWorkflowSchema);
186
202
  const workflow = await engine.createWorkflow(body);
187
203
  return this.jsonResponse({ workflow }, 201);
188
204
  }
@@ -193,7 +209,7 @@ export class CriticalPathRouter {
193
209
  return this.jsonResponse({ workflow });
194
210
  }
195
211
  if (method === 'PATCH' || method === 'PUT') {
196
- const body = await this.readJson(request);
212
+ const body = await this.readBody(request, schemas.UpdateWorkflowSchema);
197
213
  const updated = await engine.updateWorkflow(workflowId, body);
198
214
  if (!updated) return this.jsonResponse({ error: 'Workflow not found' }, 404);
199
215
  return this.jsonResponse({ workflow: updated });
@@ -216,7 +232,7 @@ export class CriticalPathRouter {
216
232
  return this.jsonResponse({ projects });
217
233
  }
218
234
  if (method === 'POST') {
219
- const body = await this.readJson(request);
235
+ const body = await this.readBody(request, schemas.CreateProjectSchema);
220
236
  const project = await engine.createProject(body);
221
237
  return this.jsonResponse({ project }, 201);
222
238
  }
@@ -261,7 +277,7 @@ export class CriticalPathRouter {
261
277
  return this.jsonResponse({ project });
262
278
  }
263
279
  if (method === 'PATCH' || method === 'PUT') {
264
- const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...updates } = await this.readJson(request);
280
+ const updates = await this.readBody(request, schemas.UpdateProjectSchema);
265
281
  const project = await engine.updateProject(projectId, updates);
266
282
  if (!project) return this.jsonResponse({ error: 'Project not found' }, 404);
267
283
  return this.jsonResponse({ project });
@@ -286,7 +302,7 @@ export class CriticalPathRouter {
286
302
  return this.jsonResponse({ tasks });
287
303
  }
288
304
  if (method === 'POST') {
289
- const body = await this.readJson(request);
305
+ const body = await this.readBody(request, schemas.CreateTaskSchema);
290
306
  const task = await engine.createTask(body);
291
307
  return this.jsonResponse({ task }, 201);
292
308
  }
@@ -296,8 +312,8 @@ export class CriticalPathRouter {
296
312
  return this.jsonResponse({ comments });
297
313
  }
298
314
  if (method === 'POST') {
299
- const body = await this.readJson(request);
300
- const comment = await engine.addComment({ ...body, taskId });
315
+ const body = await this.readBody(request, schemas.CreateCommentSchema, { taskId });
316
+ const comment = await engine.addComment(body);
301
317
  return this.jsonResponse({ comment }, 201);
302
318
  }
303
319
  } else if (subResource === 'attachments') {
@@ -306,8 +322,8 @@ export class CriticalPathRouter {
306
322
  return this.jsonResponse({ attachments });
307
323
  }
308
324
  if (method === 'POST') {
309
- const body = await this.readJson(request);
310
- const attachment = await engine.createAttachment({ ...body, taskId });
325
+ const body = await this.readBody(request, schemas.CreateAttachmentSchema, { taskId });
326
+ const attachment = await engine.createAttachment(body);
311
327
  return this.jsonResponse({ attachment }, 201);
312
328
  }
313
329
  } else if (subResource === 'dependencies') {
@@ -316,10 +332,7 @@ export class CriticalPathRouter {
316
332
  return this.jsonResponse({ graph });
317
333
  }
318
334
  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
- }
335
+ const body = await this.readBody(request, schemas.CreateDependencySchema);
323
336
  const dep = await engine.addDependency({
324
337
  taskId,
325
338
  dependsOnTaskId: body.dependsOnTaskId,
@@ -364,7 +377,7 @@ export class CriticalPathRouter {
364
377
  return this.jsonResponse({ task });
365
378
  }
366
379
  if (method === 'PATCH' || method === 'PUT') {
367
- const body = await this.readJson(request);
380
+ const body = await this.readBody(request, schemas.UpdateTaskSchema);
368
381
  const updated = await engine.updateTask(taskId, body);
369
382
  if (!updated) return this.jsonResponse({ error: 'Task not found' }, 404);
370
383
  return this.jsonResponse({ task: updated });
@@ -386,7 +399,7 @@ export class CriticalPathRouter {
386
399
  return this.jsonResponse({ teams });
387
400
  }
388
401
  if (method === 'POST') {
389
- const body = await this.readJson(request);
402
+ const body = await this.readBody(request, schemas.CreateTeamSchema);
390
403
  const team = await engine.createTeam(body);
391
404
  return this.jsonResponse({ team }, 201);
392
405
  }
@@ -397,7 +410,7 @@ export class CriticalPathRouter {
397
410
  return this.jsonResponse({ team });
398
411
  }
399
412
  if (method === 'PATCH' || method === 'PUT') {
400
- const body = await this.readJson(request);
413
+ const body = await this.readBody(request, schemas.UpdateTeamSchema);
401
414
  const updated = await engine.updateTeam(teamId, body);
402
415
  if (!updated) return this.jsonResponse({ error: 'Team not found' }, 404);
403
416
  return this.jsonResponse({ team: updated });
@@ -421,7 +434,7 @@ export class CriticalPathRouter {
421
434
  return this.jsonResponse({ containers });
422
435
  }
423
436
  if (method === 'POST') {
424
- const body = await this.readJson(request);
437
+ const body = await this.readBody(request, schemas.CreateContainerSchema);
425
438
  const container = await engine.createContainer(body);
426
439
  return this.jsonResponse({ container }, 201);
427
440
  }
@@ -432,7 +445,7 @@ export class CriticalPathRouter {
432
445
  return this.jsonResponse({ container });
433
446
  }
434
447
  if (method === 'PATCH' || method === 'PUT') {
435
- const body = await this.readJson(request);
448
+ const body = await this.readBody(request, schemas.UpdateContainerSchema);
436
449
  const updated = await engine.updateContainer(containerId, body);
437
450
  if (!updated) return this.jsonResponse({ error: 'Container not found' }, 404);
438
451
  return this.jsonResponse({ container: updated });
@@ -456,7 +469,7 @@ export class CriticalPathRouter {
456
469
  return this.jsonResponse({ deliverables });
457
470
  }
458
471
  if (method === 'POST') {
459
- const body = await this.readJson(request);
472
+ const body = await this.readBody(request, schemas.CreateDeliverableSchema);
460
473
  const deliverable = await engine.createDeliverable(body);
461
474
  return this.jsonResponse({ deliverable }, 201);
462
475
  }
@@ -473,7 +486,7 @@ export class CriticalPathRouter {
473
486
  return this.jsonResponse({ deliverable });
474
487
  }
475
488
  if (method === 'PATCH' || method === 'PUT') {
476
- const body = await this.readJson(request);
489
+ const body = await this.readBody(request, schemas.UpdateDeliverableSchema);
477
490
  const updated = await engine.updateDeliverable(deliverableId, body);
478
491
  if (!updated) return this.jsonResponse({ error: 'Deliverable not found' }, 404);
479
492
  return this.jsonResponse({ deliverable: updated });
@@ -497,7 +510,7 @@ export class CriticalPathRouter {
497
510
  return this.jsonResponse({ iterations });
498
511
  }
499
512
  if (method === 'POST') {
500
- const body = await this.readJson(request);
513
+ const body = await this.readBody(request, schemas.CreateIterationSchema);
501
514
  const iteration = await engine.createIteration(body);
502
515
  return this.jsonResponse({ iteration }, 201);
503
516
  }
@@ -508,7 +521,7 @@ export class CriticalPathRouter {
508
521
  return this.jsonResponse({ iteration });
509
522
  }
510
523
  if (method === 'PATCH' || method === 'PUT') {
511
- const body = await this.readJson(request);
524
+ const body = await this.readBody(request, schemas.UpdateIterationSchema);
512
525
  const updated = await engine.updateIteration(iterationId, body);
513
526
  if (!updated) return this.jsonResponse({ error: 'Iteration not found' }, 404);
514
527
  return this.jsonResponse({ iteration: updated });
@@ -537,28 +550,17 @@ export class CriticalPathRouter {
537
550
  const subResource = segments[2];
538
551
  if (commentId && subResource === 'reactions') {
539
552
  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
- }
545
- const comment = await engine.addCommentReaction(commentId, { emoji: body.emoji, userId });
553
+ const body = await this.readBody(request, schemas.CommentReactionSchema);
554
+ const comment = await engine.addCommentReaction(commentId, body);
546
555
  if (!comment) return this.jsonResponse({ error: 'Comment not found' }, 404);
547
556
  return this.jsonResponse({ comment }, 200);
548
557
  }
549
558
  if (method === 'DELETE') {
550
- let body: any = {};
551
- try {
552
- body = await this.readJson(request);
553
- } catch {
554
- // Body may be empty on DELETE, fallback to searchParams
555
- }
556
- const emoji = body.emoji || url.searchParams.get('emoji');
557
- const userId = context?.userId ?? (body.userId || url.searchParams.get('userId'));
558
- if (!emoji || !userId) {
559
- return this.jsonResponse({ error: 'emoji and userId are required' }, 400);
559
+ const emoji = url.searchParams.get('emoji');
560
+ if (!emoji) {
561
+ return this.jsonResponse({ error: 'emoji query parameter is required' }, 400);
560
562
  }
561
- const comment = await engine.removeCommentReaction(commentId, { emoji, userId });
563
+ const comment = await engine.removeCommentReaction(commentId, { emoji });
562
564
  if (!comment) return this.jsonResponse({ error: 'Comment not found' }, 404);
563
565
  return this.jsonResponse({ comment }, 200);
564
566
  }
@@ -570,7 +572,7 @@ export class CriticalPathRouter {
570
572
  return this.jsonResponse({ comments });
571
573
  }
572
574
  if (method === 'POST') {
573
- const body = await this.readJson(request);
575
+ const body = await this.readBody(request, schemas.CreateCommentSchema);
574
576
  const comment = await engine.addComment(body);
575
577
  return this.jsonResponse({ comment }, 201);
576
578
  }
@@ -581,7 +583,7 @@ export class CriticalPathRouter {
581
583
  return this.jsonResponse({ comment });
582
584
  }
583
585
  if (method === 'PATCH' || method === 'PUT') {
584
- const body = await this.readJson(request);
586
+ const body = await this.readBody(request, schemas.UpdateCommentSchema);
585
587
  const updated = await engine.updateComment(commentId, body);
586
588
  if (!updated) return this.jsonResponse({ error: 'Comment not found' }, 404);
587
589
  return this.jsonResponse({ comment: updated });
@@ -599,13 +601,13 @@ export class CriticalPathRouter {
599
601
  const attachmentId = segments[1];
600
602
  if (attachmentId === 'presign') {
601
603
  if (method === 'POST') {
602
- const body = await this.readJson(request);
604
+ const body = await this.readBody(request, schemas.PresignAttachmentSchema);
603
605
  const presigned = await engine.getPresignedAttachmentUploadUrl(body);
604
606
  return this.jsonResponse({ presigned });
605
607
  }
606
608
  } else if (attachmentId === 'upload') {
607
609
  if (method === 'POST') {
608
- const body = await this.readJson(request);
610
+ const body = await this.readBody(request, schemas.UploadAttachmentSchema);
609
611
  const attachment = await engine.uploadAttachmentFile(body);
610
612
  return this.jsonResponse({ attachment }, 201);
611
613
  }
@@ -618,7 +620,7 @@ export class CriticalPathRouter {
618
620
  return this.jsonResponse({ attachments });
619
621
  }
620
622
  if (method === 'POST') {
621
- const body = await this.readJson(request);
623
+ const body = await this.readBody(request, schemas.CreateAttachmentSchema);
622
624
  const attachment = await engine.createAttachment(body);
623
625
  return this.jsonResponse({ attachment }, 201);
624
626
  }
@@ -645,7 +647,7 @@ export class CriticalPathRouter {
645
647
  return this.jsonResponse({ timeEntries: entries });
646
648
  }
647
649
  if (method === 'POST') {
648
- const body = await this.readJson(request);
650
+ const body = await this.readBody(request, schemas.LogTimeSchema);
649
651
  const entry = await engine.logTime(body);
650
652
  return this.jsonResponse({ timeEntry: entry }, 201);
651
653
  }
@@ -709,6 +711,26 @@ export class CriticalPathRouter {
709
711
  }
710
712
  }
711
713
 
714
+ /**
715
+ * Reads and validates a JSON body. `overrides` (e.g. a `taskId` from the URL) are applied before
716
+ * validation so path parameters always win over body fields.
717
+ */
718
+ private async readBody<S extends { safeParse(data: unknown): unknown }>(
719
+ request: Request,
720
+ schema: S,
721
+ overrides: Record<string, unknown> = {}
722
+ ): Promise<S extends { _output: infer O } ? O : never> {
723
+ const raw = await this.readJson(request);
724
+ const body = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
725
+ return schemas.parsePayload(schema as any, { ...body, ...overrides });
726
+ }
727
+
728
+ /** The API root as seen by this request, e.g. `https://host/api/critical-path`. */
729
+ private openApiServerUrl(url: URL, subpath: string): string {
730
+ const root = url.pathname.slice(0, url.pathname.length - subpath.length).replace(/\/+$/, '');
731
+ return `${url.origin}${root}`;
732
+ }
733
+
712
734
  private async readJson(request: Request): Promise<any> {
713
735
  try {
714
736
  return await request.json();
@@ -723,9 +745,10 @@ export class CriticalPathRouter {
723
745
 
724
746
  switch (name) {
725
747
  case 'BadRequestError':
726
- case 'ValidationError':
727
748
  case 'AttachmentValidationError':
728
749
  return this.jsonResponse({ error: e.message }, 400);
750
+ case 'ValidationError':
751
+ return this.jsonResponse(e.issues ? { error: e.message, issues: e.issues } : { error: e.message }, 400);
729
752
  case 'WorkflowValidationError':
730
753
  return this.jsonResponse({ error: e.message, fromStatus: e.fromStatus, toStatus: e.toStatus }, 400);
731
754
  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"}