@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/CHANGELOG.md +60 -0
- package/README.md +22 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +93 -24
- package/dist/index.test.js.map +1 -1
- package/dist/openapi.d.ts +30 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +139 -0
- package/dist/openapi.js.map +1 -0
- package/dist/openapi.test.d.ts +2 -0
- package/dist/openapi.test.d.ts.map +1 -0
- package/dist/openapi.test.js +86 -0
- package/dist/openapi.test.js.map +1 -0
- package/dist/router.d.ts +19 -3
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +60 -51
- package/dist/router.js.map +1 -1
- package/package.json +3 -2
- package/src/index.test.ts +116 -24
- package/src/index.ts +1 -0
- package/src/openapi.test.ts +95 -0
- package/src/openapi.ts +178 -0
- package/src/router.ts +76 -53
- package/tsconfig.tsbuildinfo +1 -1
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
|
|
55
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
300
|
-
const comment = await engine.addComment(
|
|
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.
|
|
310
|
-
const attachment = await engine.createAttachment(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
541
|
-
const
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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':
|
package/tsconfig.tsbuildinfo
CHANGED
|
@@ -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"}
|