@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/index.test.ts
CHANGED
|
@@ -111,7 +111,7 @@ describe('@critical-path/server Router Tests', () => {
|
|
|
111
111
|
const postCmtReq = new Request(`http://localhost:3000/api/critical-path/tasks/${task.id}/comments`, {
|
|
112
112
|
method: 'POST',
|
|
113
113
|
headers: { 'Content-Type': 'application/json' },
|
|
114
|
-
body: JSON.stringify({ content: 'Root comment'
|
|
114
|
+
body: JSON.stringify({ content: 'Root comment' })
|
|
115
115
|
});
|
|
116
116
|
const postCmtRes = await router.handleRequest(postCmtReq);
|
|
117
117
|
expect(postCmtRes.status).toBe(201);
|
|
@@ -146,8 +146,7 @@ describe('@critical-path/server Router Tests', () => {
|
|
|
146
146
|
mimeType: 'text/markdown',
|
|
147
147
|
sizeBytes: 100,
|
|
148
148
|
taskId: task.id,
|
|
149
|
-
projectId: proj.id
|
|
150
|
-
uploaderId: 'u1'
|
|
149
|
+
projectId: proj.id
|
|
151
150
|
})
|
|
152
151
|
});
|
|
153
152
|
const postAttRes = await router.handleRequest(postAttReq);
|
|
@@ -183,8 +182,7 @@ describe('@critical-path/server Router Tests', () => {
|
|
|
183
182
|
body: JSON.stringify({
|
|
184
183
|
filename: 'huge.pdf',
|
|
185
184
|
url: `data:application/pdf;base64,${'B'.repeat(3000)}`,
|
|
186
|
-
taskId: task.id
|
|
187
|
-
uploaderId: 'u1'
|
|
185
|
+
taskId: task.id
|
|
188
186
|
})
|
|
189
187
|
});
|
|
190
188
|
const invalidAttRes = await router.handleRequest(invalidAttReq);
|
|
@@ -203,29 +201,29 @@ describe('@critical-path/server Router Tests', () => {
|
|
|
203
201
|
const addReactReq = new Request(`http://localhost:3000/api/critical-path/comments/${comment.id}/reactions`, {
|
|
204
202
|
method: 'POST',
|
|
205
203
|
headers: { 'Content-Type': 'application/json' },
|
|
206
|
-
body: JSON.stringify({ emoji: '❤️'
|
|
204
|
+
body: JSON.stringify({ emoji: '❤️' })
|
|
207
205
|
});
|
|
208
206
|
const addReactRes = await router.handleRequest(addReactReq);
|
|
209
207
|
expect(addReactRes.status).toBe(200);
|
|
210
208
|
const addReactData = await addReactRes.json();
|
|
211
209
|
expect(addReactData.comment.reactions).toHaveLength(1);
|
|
212
210
|
expect(addReactData.comment.reactions[0].emoji).toBe('❤️');
|
|
211
|
+
expect(addReactData.comment.reactions[0].userId).toBe('anonymous');
|
|
213
212
|
|
|
214
|
-
// 2. Add reaction validation error (missing emoji or
|
|
213
|
+
// 2. Add reaction validation error (missing emoji, or identity claimed in the body)
|
|
215
214
|
const badReactReq = new Request(`http://localhost:3000/api/critical-path/comments/${comment.id}/reactions`, {
|
|
216
215
|
method: 'POST',
|
|
217
216
|
headers: { 'Content-Type': 'application/json' },
|
|
218
|
-
body: JSON.stringify({ emoji: '❤️' })
|
|
217
|
+
body: JSON.stringify({ emoji: '❤️', userId: 'u2' })
|
|
219
218
|
});
|
|
220
219
|
const badReactRes = await router.handleRequest(badReactReq);
|
|
221
220
|
expect(badReactRes.status).toBe(400);
|
|
222
221
|
|
|
223
222
|
// 3. Remove reaction via DELETE
|
|
224
|
-
const delReactReq = new Request(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
});
|
|
223
|
+
const delReactReq = new Request(
|
|
224
|
+
`http://localhost:3000/api/critical-path/comments/${comment.id}/reactions?emoji=${encodeURIComponent('❤️')}`,
|
|
225
|
+
{ method: 'DELETE' }
|
|
226
|
+
);
|
|
229
227
|
const delReactRes = await router.handleRequest(delReactReq);
|
|
230
228
|
expect(delReactRes.status).toBe(200);
|
|
231
229
|
const delReactData = await delReactRes.json();
|
|
@@ -483,11 +481,16 @@ describe('@critical-path/server Router Tests', () => {
|
|
|
483
481
|
}
|
|
484
482
|
});
|
|
485
483
|
|
|
486
|
-
it('answers CORS preflight requests', async () => {
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
expect(
|
|
484
|
+
it('answers CORS preflight requests, sending CORS headers only when configured', async () => {
|
|
485
|
+
const preflight = () => new Request(`${base}/tasks`, { method: 'OPTIONS', headers: { Origin: 'https://x.test' } });
|
|
486
|
+
|
|
487
|
+
const closed = await new CriticalPathRouter().handleRequest(preflight());
|
|
488
|
+
expect(closed.status).toBe(204);
|
|
489
|
+
expect(closed.headers.get('Access-Control-Allow-Origin')).toBeNull();
|
|
490
|
+
|
|
491
|
+
const open = await new CriticalPathRouter(undefined, { cors: { origins: '*' } }).handleRequest(preflight());
|
|
492
|
+
expect(open.headers.get('Access-Control-Allow-Origin')).toBe('*');
|
|
493
|
+
expect(open.headers.get('Access-Control-Allow-Methods')).toContain('PATCH');
|
|
491
494
|
});
|
|
492
495
|
});
|
|
493
496
|
|
|
@@ -500,7 +503,7 @@ describe('@critical-path/server Router Tests', () => {
|
|
|
500
503
|
expect((await GET(req())).status).toBe(200);
|
|
501
504
|
});
|
|
502
505
|
|
|
503
|
-
it('updates projects via PATCH
|
|
506
|
+
it('updates projects via PATCH and rejects id or timestamp overrides', async () => {
|
|
504
507
|
const router = new CriticalPathRouter();
|
|
505
508
|
const proj = await router.engine.createProject({ key: 'UPD', name: 'Before' });
|
|
506
509
|
const patch = (id: string, body: unknown) =>
|
|
@@ -512,7 +515,10 @@ describe('@critical-path/server Router Tests', () => {
|
|
|
512
515
|
})
|
|
513
516
|
);
|
|
514
517
|
|
|
515
|
-
|
|
518
|
+
expect((await patch(proj.id, { name: 'After', id: 'hijacked' })).status).toBe(400);
|
|
519
|
+
expect((await patch(proj.id, { name: 'After', createdAt: '1999-01-01T00:00:00.000Z' })).status).toBe(400);
|
|
520
|
+
|
|
521
|
+
const res = await patch(proj.id, { name: 'After' });
|
|
516
522
|
expect(res.status).toBe(200);
|
|
517
523
|
const { project } = await res.json();
|
|
518
524
|
expect(project.id).toBe(proj.id);
|
|
@@ -632,7 +638,7 @@ describe('@critical-path/server Router Tests', () => {
|
|
|
632
638
|
expect((await call(router, '/projects', { method: 'OPTIONS' })).status).toBe(204);
|
|
633
639
|
});
|
|
634
640
|
|
|
635
|
-
it('attributes mutations to the context user
|
|
641
|
+
it('attributes mutations to the context user', async () => {
|
|
636
642
|
const router = new CriticalPathRouter(undefined, { getContext: tokenContext });
|
|
637
643
|
const project = await router.engine.createProject({ key: 'CTX', name: 'Context' });
|
|
638
644
|
const task = await router.engine.createTask({ projectId: project.id, title: 'T' });
|
|
@@ -640,14 +646,14 @@ describe('@critical-path/server Router Tests', () => {
|
|
|
640
646
|
const patched = await call(router, `/tasks/${task.id}`, {
|
|
641
647
|
method: 'PATCH',
|
|
642
648
|
token: 'alice',
|
|
643
|
-
body: JSON.stringify({ title: 'Renamed'
|
|
649
|
+
body: JSON.stringify({ title: 'Renamed' })
|
|
644
650
|
});
|
|
645
651
|
expect(patched.status).toBe(200);
|
|
646
652
|
|
|
647
653
|
const commented = await call(router, `/tasks/${task.id}/comments`, {
|
|
648
654
|
method: 'POST',
|
|
649
655
|
token: 'alice',
|
|
650
|
-
body: JSON.stringify({ content: 'hi'
|
|
656
|
+
body: JSON.stringify({ content: 'hi' })
|
|
651
657
|
});
|
|
652
658
|
const { comment } = await commented.json();
|
|
653
659
|
expect(comment.authorId).toBe('alice');
|
|
@@ -727,7 +733,7 @@ describe('@critical-path/server Router Tests', () => {
|
|
|
727
733
|
new Request(`http://localhost/api/critical-path/tasks/${task.id}/comments`, {
|
|
728
734
|
method: 'POST',
|
|
729
735
|
headers: { 'Content-Type': 'application/json' },
|
|
730
|
-
body: JSON.stringify({ content: 'from svelte'
|
|
736
|
+
body: JSON.stringify({ content: 'from svelte' })
|
|
731
737
|
});
|
|
732
738
|
|
|
733
739
|
expect((await POST({ request: commentReq(), locals: {} })).status).toBe(401);
|
|
@@ -752,5 +758,91 @@ describe('@critical-path/server Router Tests', () => {
|
|
|
752
758
|
expect((await handle(new Request('http://localhost/other/projects'))).status).toBe(404);
|
|
753
759
|
});
|
|
754
760
|
});
|
|
761
|
+
|
|
762
|
+
describe('request body validation', () => {
|
|
763
|
+
const send = (router: CriticalPathRouter, method: string, path: string, body: unknown, token?: string) =>
|
|
764
|
+
router.handleRequest(
|
|
765
|
+
new Request(`http://localhost:3000/api/critical-path${path}`, {
|
|
766
|
+
method,
|
|
767
|
+
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
768
|
+
body: JSON.stringify(body)
|
|
769
|
+
})
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
it('rejects attempts to move tasks or rewrite server-assigned fields', async () => {
|
|
773
|
+
const router = new CriticalPathRouter();
|
|
774
|
+
const home = await router.engine.createProject({ key: 'HOME', name: 'Home' });
|
|
775
|
+
const other = await router.engine.createProject({ key: 'OTH', name: 'Other' });
|
|
776
|
+
const task = await router.engine.createTask({ projectId: home.id, title: 'Stay' });
|
|
777
|
+
|
|
778
|
+
const res = await send(router, 'PATCH', `/tasks/${task.id}`, {
|
|
779
|
+
title: 'Renamed',
|
|
780
|
+
projectId: other.id,
|
|
781
|
+
id: 'hijacked',
|
|
782
|
+
createdAt: '1999-01-01T00:00:00.000Z',
|
|
783
|
+
injected: '<script>'
|
|
784
|
+
});
|
|
785
|
+
expect(res.status).toBe(400);
|
|
786
|
+
const { issues } = await res.json();
|
|
787
|
+
expect(issues[0].message).toMatch(/Unrecognized key/);
|
|
788
|
+
expect(issues[0].message).toMatch(/projectId/);
|
|
789
|
+
|
|
790
|
+
const stored = await router.engine.getTask(task.id);
|
|
791
|
+
expect(stored?.title).toBe('Stay');
|
|
792
|
+
expect(stored?.projectId).toBe(home.id);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it('returns 400 with field-level issues for invalid bodies', async () => {
|
|
796
|
+
const router = new CriticalPathRouter();
|
|
797
|
+
const project = await router.engine.createProject({ key: 'VAL', name: 'Validation' });
|
|
798
|
+
|
|
799
|
+
const res = await send(router, 'POST', '/tasks', { projectId: project.id, title: '', progress: 'half' });
|
|
800
|
+
expect(res.status).toBe(400);
|
|
801
|
+
const data = await res.json();
|
|
802
|
+
expect(data.issues.map((i: { path: string }) => i.path).sort()).toEqual(['progress', 'title']);
|
|
803
|
+
|
|
804
|
+
expect((await send(router, 'POST', '/projects', [])).status).toBe(400);
|
|
805
|
+
expect((await send(router, 'POST', '/iterations', { projectId: project.id, name: 'S1', status: 'later' })).status).toBe(400);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it('takes authorship from the caller (or anonymous), never the body, and path ids win', async () => {
|
|
809
|
+
const router = new CriticalPathRouter(undefined, {
|
|
810
|
+
getContext: (request) => {
|
|
811
|
+
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
|
|
812
|
+
return token ? { userId: token } : null;
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
const project = await router.engine.createProject({ key: 'AUT', name: 'Authors' });
|
|
816
|
+
const task = await router.engine.createTask({ projectId: project.id, title: 'T' });
|
|
817
|
+
const decoy = await router.engine.createTask({ projectId: project.id, title: 'Decoy' });
|
|
818
|
+
|
|
819
|
+
const anonymous = await send(router, 'POST', `/tasks/${task.id}/comments`, { content: 'hi' });
|
|
820
|
+
expect(anonymous.status).toBe(201);
|
|
821
|
+
expect((await anonymous.json()).comment.authorId).toBe('anonymous');
|
|
822
|
+
|
|
823
|
+
const spoofed = await send(router, 'POST', `/tasks/${task.id}/comments`, { content: 'hi', authorId: 'ceo' }, 'alice');
|
|
824
|
+
expect(spoofed.status).toBe(400);
|
|
825
|
+
|
|
826
|
+
const authed = await send(router, 'POST', `/tasks/${task.id}/comments`, { content: 'hi', taskId: decoy.id }, 'alice');
|
|
827
|
+
expect(authed.status).toBe(201);
|
|
828
|
+
const { comment } = await authed.json();
|
|
829
|
+
expect(comment.authorId).toBe('alice');
|
|
830
|
+
expect(comment.taskId).toBe(task.id);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('attributes unauthenticated writes to the anonymous actor and rejects actor claims', async () => {
|
|
834
|
+
const router = new CriticalPathRouter();
|
|
835
|
+
const project = await router.engine.createProject({ key: 'ANO', name: 'Anonymous' });
|
|
836
|
+
const task = await router.engine.createTask({ projectId: project.id, title: 'T' });
|
|
837
|
+
|
|
838
|
+
const claimed = await send(router, 'PATCH', `/tasks/${task.id}`, { isBlocked: true, actorId: 'agent-7' });
|
|
839
|
+
expect(claimed.status).toBe(400);
|
|
840
|
+
|
|
841
|
+
const res = await send(router, 'PATCH', `/tasks/${task.id}`, { isBlocked: true });
|
|
842
|
+
expect(res.status).toBe(200);
|
|
843
|
+
const activities = await router.engine.store.getActivities({ taskId: task.id });
|
|
844
|
+
expect(activities.some((a) => a.actorId === 'anonymous')).toBe(true);
|
|
845
|
+
});
|
|
846
|
+
});
|
|
755
847
|
});
|
|
756
848
|
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { CriticalPathRouter } from './router.js';
|
|
3
|
+
import { ROUTES, buildOpenApiDocument } from './openapi.js';
|
|
4
|
+
|
|
5
|
+
const base = 'http://localhost:3000/api/critical-path';
|
|
6
|
+
|
|
7
|
+
describe('OpenAPI document', () => {
|
|
8
|
+
it('documents every route the router serves, with matching response envelopes', async () => {
|
|
9
|
+
const router = new CriticalPathRouter();
|
|
10
|
+
const engine = router.engine;
|
|
11
|
+
const project = await engine.createProject({ key: 'OAS', name: 'OpenAPI' });
|
|
12
|
+
const task = await engine.createTask({ projectId: project.id, title: 'Task' });
|
|
13
|
+
const workflow = await engine.createWorkflow({ name: 'WF', statuses: [], transitions: [] });
|
|
14
|
+
const team = await engine.createTeam({ name: 'Team', memberIds: [] });
|
|
15
|
+
const container = await engine.createContainer({ projectId: project.id, name: 'Epic' });
|
|
16
|
+
const deliverable = await engine.createDeliverable({ projectId: project.id, title: 'Cut' });
|
|
17
|
+
const iteration = await engine.createIteration({ projectId: project.id, name: 'S1', status: 'planning' });
|
|
18
|
+
const comment = await engine.addComment({ taskId: task.id, content: 'hi', authorId: 'u1' });
|
|
19
|
+
const attachment = await engine.createAttachment({
|
|
20
|
+
taskId: task.id,
|
|
21
|
+
uploaderId: 'u1',
|
|
22
|
+
filename: 'a.md',
|
|
23
|
+
mimeType: 'text/markdown',
|
|
24
|
+
sizeBytes: 1,
|
|
25
|
+
url: 'https://example.com/a.md'
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const ids: Record<string, string> = {
|
|
29
|
+
projectId: project.id,
|
|
30
|
+
taskId: task.id,
|
|
31
|
+
workflowId: workflow.id,
|
|
32
|
+
teamId: team.id,
|
|
33
|
+
containerId: container.id,
|
|
34
|
+
deliverableId: deliverable.id,
|
|
35
|
+
iterationId: iteration.id,
|
|
36
|
+
commentId: comment.id,
|
|
37
|
+
attachmentId: attachment.id
|
|
38
|
+
};
|
|
39
|
+
const query: Record<string, string> = { projectId: project.id, taskId: task.id, emoji: '👍', userId: 'u1' };
|
|
40
|
+
|
|
41
|
+
// Deletes run last so earlier routes still have data; the project goes last of all.
|
|
42
|
+
const ordered = [...ROUTES].sort((a, b) => {
|
|
43
|
+
const rank = (r: (typeof ROUTES)[number]) => (r.method === 'delete' ? (r.path === '/projects/{projectId}' ? 2 : 1) : 0);
|
|
44
|
+
return rank(a) - rank(b);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
for (const route of ordered) {
|
|
48
|
+
const path = route.path.replace(/\{(\w+)\}/g, (_, name) => ids[name]);
|
|
49
|
+
const params = new URLSearchParams();
|
|
50
|
+
for (const q of route.query ?? []) {
|
|
51
|
+
const name = q.replace(/!$/, '');
|
|
52
|
+
if (query[name]) params.set(name, query[name]);
|
|
53
|
+
}
|
|
54
|
+
const res = await router.handleRequest(
|
|
55
|
+
new Request(`${base}${path}${params.size ? `?${params}` : ''}`, {
|
|
56
|
+
method: route.method.toUpperCase(),
|
|
57
|
+
headers: { 'Content-Type': 'application/json' },
|
|
58
|
+
...(route.method === 'post' || route.method === 'patch' ? { body: '{}' } : {})
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
const body = await res.json();
|
|
62
|
+
const label = `${route.method.toUpperCase()} ${route.path}`;
|
|
63
|
+
expect(String(body.error ?? ''), label).not.toMatch(/^Route not found/);
|
|
64
|
+
if (res.status < 300 && route.responseKey) {
|
|
65
|
+
expect(body, label).toHaveProperty(route.responseKey);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('serves the document at /openapi.json with request bodies from the validation schemas', async () => {
|
|
71
|
+
const router = new CriticalPathRouter();
|
|
72
|
+
const res = await router.handleRequest(new Request(`${base}/openapi.json`));
|
|
73
|
+
expect(res.status).toBe(200);
|
|
74
|
+
const doc = await res.json();
|
|
75
|
+
|
|
76
|
+
expect(doc.openapi).toBe('3.1.0');
|
|
77
|
+
expect(doc.servers[0].url).toBe('http://localhost:3000/api/critical-path');
|
|
78
|
+
const createTask = doc.paths['/tasks'].post.requestBody.content['application/json'].schema;
|
|
79
|
+
expect(createTask.required).toEqual(expect.arrayContaining(['projectId', 'title']));
|
|
80
|
+
expect(createTask.properties).not.toHaveProperty('id');
|
|
81
|
+
expect(doc.paths['/tasks/{taskId}/dependencies'].post.responses).toHaveProperty('409');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('respects requireAuth', async () => {
|
|
85
|
+
const router = new CriticalPathRouter(undefined, { requireAuth: true });
|
|
86
|
+
const res = await router.handleRequest(new Request(`${base}/openapi.json`));
|
|
87
|
+
expect(res.status).toBe(401);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('can be built without a router for static publishing', () => {
|
|
91
|
+
const doc = buildOpenApiDocument({ serverUrl: 'https://api.example.com/pm', version: '2.0.0' }) as any;
|
|
92
|
+
expect(doc.info.version).toBe('2.0.0');
|
|
93
|
+
expect(Object.keys(doc.paths).length).toBeGreaterThan(30);
|
|
94
|
+
});
|
|
95
|
+
});
|
package/src/openapi.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
2
|
+
import * as schemas from '@critical-path/core/schemas';
|
|
3
|
+
|
|
4
|
+
type Method = 'get' | 'post' | 'patch' | 'delete';
|
|
5
|
+
|
|
6
|
+
interface RouteDoc {
|
|
7
|
+
method: Method;
|
|
8
|
+
path: string;
|
|
9
|
+
summary: string;
|
|
10
|
+
tag: string;
|
|
11
|
+
/** Query parameters; names ending in `!` are required. */
|
|
12
|
+
query?: string[];
|
|
13
|
+
body?: { safeParse(data: unknown): unknown };
|
|
14
|
+
/** Key of the JSON response envelope, e.g. `task` for `{ "task": { ... } }`. */
|
|
15
|
+
responseKey?: string;
|
|
16
|
+
status?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Every route served by `CriticalPathRouter`. Kept in sync by the route drift test. */
|
|
20
|
+
export const ROUTES: RouteDoc[] = [
|
|
21
|
+
{ method: 'get', path: '/workflows', summary: 'List workflows', tag: 'Workflows', responseKey: 'workflows' },
|
|
22
|
+
{ method: 'post', path: '/workflows', summary: 'Create a workflow', tag: 'Workflows', body: schemas.CreateWorkflowSchema, responseKey: 'workflow', status: 201 },
|
|
23
|
+
{ method: 'get', path: '/workflows/{workflowId}', summary: 'Get a workflow', tag: 'Workflows', responseKey: 'workflow' },
|
|
24
|
+
{ method: 'patch', path: '/workflows/{workflowId}', summary: 'Update a workflow', tag: 'Workflows', body: schemas.UpdateWorkflowSchema, responseKey: 'workflow' },
|
|
25
|
+
{ method: 'delete', path: '/workflows/{workflowId}', summary: 'Delete a workflow', tag: 'Workflows' },
|
|
26
|
+
|
|
27
|
+
{ method: 'get', path: '/projects', summary: 'List projects', tag: 'Projects', responseKey: 'projects' },
|
|
28
|
+
{ method: 'post', path: '/projects', summary: 'Create a project', tag: 'Projects', body: schemas.CreateProjectSchema, responseKey: 'project', status: 201 },
|
|
29
|
+
{ method: 'get', path: '/projects/{projectId}', summary: 'Get a project', tag: 'Projects', responseKey: 'project' },
|
|
30
|
+
{ method: 'patch', path: '/projects/{projectId}', summary: 'Update a project', tag: 'Projects', body: schemas.UpdateProjectSchema, responseKey: 'project' },
|
|
31
|
+
{ method: 'delete', path: '/projects/{projectId}', summary: 'Delete a project and its tasks', tag: 'Projects' },
|
|
32
|
+
{ method: 'get', path: '/projects/{projectId}/critical-path', summary: 'Critical path (CPM) analysis', tag: 'Analysis', responseKey: 'analysis' },
|
|
33
|
+
{ method: 'get', path: '/projects/{projectId}/ladder', summary: 'Timeline ladder of abstraction', tag: 'Analysis', query: ['level', 'containerId', 'iterationId'], responseKey: 'ladder' },
|
|
34
|
+
{ method: 'get', path: '/projects/{projectId}/workload', summary: 'Project workload distribution', tag: 'Analysis', query: ['startDate', 'endDate', 'interval', 'groupBy', 'metric', 'defaultWeeklyCapacityHours'], responseKey: 'workload' },
|
|
35
|
+
|
|
36
|
+
{ method: 'get', path: '/tasks', summary: 'List tasks', tag: 'Tasks', query: ['projectId'], responseKey: 'tasks' },
|
|
37
|
+
{ method: 'post', path: '/tasks', summary: 'Create a task', tag: 'Tasks', body: schemas.CreateTaskSchema, responseKey: 'task', status: 201 },
|
|
38
|
+
{ method: 'get', path: '/tasks/{taskId}', summary: 'Get a task', tag: 'Tasks', responseKey: 'task' },
|
|
39
|
+
{ method: 'patch', path: '/tasks/{taskId}', summary: 'Update a task (enforces workflow transitions)', tag: 'Tasks', body: schemas.UpdateTaskSchema, responseKey: 'task' },
|
|
40
|
+
{ method: 'delete', path: '/tasks/{taskId}', summary: 'Delete a task', tag: 'Tasks' },
|
|
41
|
+
{ method: 'get', path: '/tasks/{taskId}/comments', summary: 'List task comments', tag: 'Comments', responseKey: 'comments' },
|
|
42
|
+
{ method: 'post', path: '/tasks/{taskId}/comments', summary: 'Comment on a task', tag: 'Comments', body: schemas.CreateCommentSchema.omit({ taskId: true }), responseKey: 'comment', status: 201 },
|
|
43
|
+
{ method: 'get', path: '/tasks/{taskId}/attachments', summary: 'List task attachments', tag: 'Attachments', responseKey: 'attachments' },
|
|
44
|
+
{ method: 'post', path: '/tasks/{taskId}/attachments', summary: 'Attach a file link to a task', tag: 'Attachments', body: schemas.CreateAttachmentSchema.omit({ taskId: true }), responseKey: 'attachment', status: 201 },
|
|
45
|
+
{ method: 'get', path: '/tasks/{taskId}/dependencies', summary: 'Task dependency graph', tag: 'Dependencies', responseKey: 'graph' },
|
|
46
|
+
{ method: 'post', path: '/tasks/{taskId}/dependencies', summary: 'Add a dependency (409 on cycles)', tag: 'Dependencies', body: schemas.CreateDependencySchema, responseKey: 'dependency', status: 201 },
|
|
47
|
+
{ method: 'get', path: '/tasks/{taskId}/lifecycle', summary: 'Derived lifecycle state', tag: 'Tasks', responseKey: 'state' },
|
|
48
|
+
{ method: 'get', path: '/tasks/{taskId}/transitions', summary: 'Allowed status transitions', tag: 'Tasks' },
|
|
49
|
+
{ method: 'get', path: '/tasks/{taskId}/ladder', summary: 'Single-task ladder view', tag: 'Analysis', responseKey: 'taskLadder' },
|
|
50
|
+
{ method: 'get', path: '/tasks/{taskId}/metrics', summary: 'Task metrics and EVM', tag: 'Analysis', responseKey: 'metrics' },
|
|
51
|
+
{ method: 'get', path: '/tasks/{taskId}/progress-history', summary: 'Reconstructed progress history', tag: 'Analysis', responseKey: 'progressHistory' },
|
|
52
|
+
|
|
53
|
+
{ method: 'get', path: '/teams', summary: 'List teams', tag: 'Teams', responseKey: 'teams' },
|
|
54
|
+
{ method: 'post', path: '/teams', summary: 'Create a team', tag: 'Teams', body: schemas.CreateTeamSchema, responseKey: 'team', status: 201 },
|
|
55
|
+
{ method: 'get', path: '/teams/{teamId}', summary: 'Get a team', tag: 'Teams', responseKey: 'team' },
|
|
56
|
+
{ method: 'patch', path: '/teams/{teamId}', summary: 'Update a team', tag: 'Teams', body: schemas.UpdateTeamSchema, responseKey: 'team' },
|
|
57
|
+
{ method: 'delete', path: '/teams/{teamId}', summary: 'Delete a team', tag: 'Teams' },
|
|
58
|
+
|
|
59
|
+
{ method: 'get', path: '/containers', summary: 'List containers', tag: 'Containers', query: ['projectId!'], responseKey: 'containers' },
|
|
60
|
+
{ method: 'post', path: '/containers', summary: 'Create a container', tag: 'Containers', body: schemas.CreateContainerSchema, responseKey: 'container', status: 201 },
|
|
61
|
+
{ method: 'get', path: '/containers/{containerId}', summary: 'Get a container', tag: 'Containers', responseKey: 'container' },
|
|
62
|
+
{ method: 'patch', path: '/containers/{containerId}', summary: 'Update a container', tag: 'Containers', body: schemas.UpdateContainerSchema, responseKey: 'container' },
|
|
63
|
+
{ method: 'delete', path: '/containers/{containerId}', summary: 'Delete a container', tag: 'Containers' },
|
|
64
|
+
|
|
65
|
+
{ method: 'get', path: '/deliverables', summary: 'List deliverables', tag: 'Deliverables', query: ['projectId!'], responseKey: 'deliverables' },
|
|
66
|
+
{ method: 'post', path: '/deliverables', summary: 'Create a deliverable', tag: 'Deliverables', body: schemas.CreateDeliverableSchema, responseKey: 'deliverable', status: 201 },
|
|
67
|
+
{ method: 'get', path: '/deliverables/{deliverableId}', summary: 'Get a deliverable', tag: 'Deliverables', responseKey: 'deliverable' },
|
|
68
|
+
{ method: 'get', path: '/deliverables/{deliverableId}/summary', summary: 'Deliverable rollup summary', tag: 'Deliverables', responseKey: 'summary' },
|
|
69
|
+
{ method: 'patch', path: '/deliverables/{deliverableId}', summary: 'Update a deliverable', tag: 'Deliverables', body: schemas.UpdateDeliverableSchema, responseKey: 'deliverable' },
|
|
70
|
+
{ method: 'delete', path: '/deliverables/{deliverableId}', summary: 'Delete a deliverable', tag: 'Deliverables' },
|
|
71
|
+
|
|
72
|
+
{ method: 'get', path: '/iterations', summary: 'List iterations', tag: 'Iterations', query: ['projectId!'], responseKey: 'iterations' },
|
|
73
|
+
{ method: 'post', path: '/iterations', summary: 'Create an iteration', tag: 'Iterations', body: schemas.CreateIterationSchema, responseKey: 'iteration', status: 201 },
|
|
74
|
+
{ method: 'get', path: '/iterations/{iterationId}', summary: 'Get an iteration', tag: 'Iterations', responseKey: 'iteration' },
|
|
75
|
+
{ method: 'patch', path: '/iterations/{iterationId}', summary: 'Update an iteration', tag: 'Iterations', body: schemas.UpdateIterationSchema, responseKey: 'iteration' },
|
|
76
|
+
{ method: 'delete', path: '/iterations/{iterationId}', summary: 'Delete an iteration', tag: 'Iterations' },
|
|
77
|
+
|
|
78
|
+
{ method: 'get', path: '/activities', summary: 'Activity (audit) stream', tag: 'Activity', query: ['projectId', 'taskId'], responseKey: 'activities' },
|
|
79
|
+
|
|
80
|
+
{ method: 'get', path: '/comments', summary: 'List comments for a task', tag: 'Comments', query: ['taskId!'], responseKey: 'comments' },
|
|
81
|
+
{ method: 'post', path: '/comments', summary: 'Create a comment', tag: 'Comments', body: schemas.CreateCommentSchema, responseKey: 'comment', status: 201 },
|
|
82
|
+
{ method: 'get', path: '/comments/{commentId}', summary: 'Get a comment', tag: 'Comments', responseKey: 'comment' },
|
|
83
|
+
{ method: 'patch', path: '/comments/{commentId}', summary: 'Update a comment', tag: 'Comments', body: schemas.UpdateCommentSchema, responseKey: 'comment' },
|
|
84
|
+
{ method: 'delete', path: '/comments/{commentId}', summary: 'Delete a comment', tag: 'Comments' },
|
|
85
|
+
{ method: 'post', path: '/comments/{commentId}/reactions', summary: 'Add a reaction', tag: 'Comments', body: schemas.CommentReactionSchema, responseKey: 'comment' },
|
|
86
|
+
{ method: 'delete', path: '/comments/{commentId}/reactions', summary: 'Remove a reaction', tag: 'Comments', query: ['emoji!'], responseKey: 'comment' },
|
|
87
|
+
|
|
88
|
+
{ method: 'get', path: '/attachments', summary: 'List attachments', tag: 'Attachments', query: ['taskId', 'projectId', 'commentId'], responseKey: 'attachments' },
|
|
89
|
+
{ method: 'post', path: '/attachments', summary: 'Register an attachment', tag: 'Attachments', body: schemas.CreateAttachmentSchema, responseKey: 'attachment', status: 201 },
|
|
90
|
+
{ method: 'post', path: '/attachments/upload', summary: 'Upload a file through the configured FileStorageAdapter', tag: 'Attachments', body: schemas.UploadAttachmentSchema, responseKey: 'attachment', status: 201 },
|
|
91
|
+
{ method: 'post', path: '/attachments/presign', summary: 'Request a presigned upload URL', tag: 'Attachments', body: schemas.PresignAttachmentSchema, responseKey: 'presigned' },
|
|
92
|
+
{ method: 'get', path: '/attachments/{attachmentId}', summary: 'Get an attachment', tag: 'Attachments', responseKey: 'attachment' },
|
|
93
|
+
{ method: 'delete', path: '/attachments/{attachmentId}', summary: 'Delete an attachment', tag: 'Attachments' },
|
|
94
|
+
|
|
95
|
+
{ method: 'get', path: '/time-entries', summary: 'List time entries for a task', tag: 'Time Tracking', query: ['taskId!'], responseKey: 'timeEntries' },
|
|
96
|
+
{ method: 'post', path: '/time-entries', summary: 'Log time against a task', tag: 'Time Tracking', body: schemas.LogTimeSchema, responseKey: 'timeEntry', status: 201 },
|
|
97
|
+
|
|
98
|
+
{ method: 'get', path: '/workload', summary: 'Workload distribution across projects', tag: 'Analysis', query: ['projectId', 'startDate', 'endDate', 'interval', 'groupBy', 'metric', 'defaultWeeklyCapacityHours'], responseKey: 'workload' },
|
|
99
|
+
{ method: 'post', path: '/status', summary: 'Publish an agent status update', tag: 'Agents' }
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const errorResponse = {
|
|
103
|
+
description: 'Error',
|
|
104
|
+
content: {
|
|
105
|
+
'application/json': {
|
|
106
|
+
schema: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: {
|
|
109
|
+
error: { type: 'string' },
|
|
110
|
+
issues: { type: 'array', items: { type: 'object', properties: { path: { type: 'string' }, message: { type: 'string' } } } }
|
|
111
|
+
},
|
|
112
|
+
required: ['error']
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
function toJsonSchema(schema: unknown): Record<string, unknown> {
|
|
119
|
+
const { $schema: _ignored, ...json } = zodToJsonSchema(schema as any, { $refStrategy: 'none' }) as Record<string, unknown>;
|
|
120
|
+
return json;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface OpenApiOptions {
|
|
124
|
+
/** Public URL the API is served from, e.g. `https://app.example.com/api/critical-path`. */
|
|
125
|
+
serverUrl?: string;
|
|
126
|
+
title?: string;
|
|
127
|
+
version?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Builds an OpenAPI 3.1 document for the router. Request bodies are generated from the same zod
|
|
132
|
+
* schemas the router validates with, so the contract cannot drift from enforcement.
|
|
133
|
+
*/
|
|
134
|
+
export function buildOpenApiDocument(options: OpenApiOptions = {}): Record<string, unknown> {
|
|
135
|
+
const paths: Record<string, Record<string, unknown>> = {};
|
|
136
|
+
|
|
137
|
+
for (const route of ROUTES) {
|
|
138
|
+
const pathParams = [...route.path.matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
|
|
139
|
+
const parameters = [
|
|
140
|
+
...pathParams.map((name) => ({ name, in: 'path', required: true, schema: { type: 'string' } })),
|
|
141
|
+
...(route.query ?? []).map((q) => ({
|
|
142
|
+
name: q.replace(/!$/, ''),
|
|
143
|
+
in: 'query',
|
|
144
|
+
required: q.endsWith('!'),
|
|
145
|
+
schema: { type: 'string' }
|
|
146
|
+
}))
|
|
147
|
+
];
|
|
148
|
+
const status = String(route.status ?? 200);
|
|
149
|
+
const successSchema = route.responseKey
|
|
150
|
+
? { type: 'object', properties: { [route.responseKey]: {} }, required: [route.responseKey] }
|
|
151
|
+
: { type: 'object' };
|
|
152
|
+
|
|
153
|
+
paths[route.path] ??= {};
|
|
154
|
+
paths[route.path][route.method] = {
|
|
155
|
+
summary: route.summary,
|
|
156
|
+
tags: [route.tag],
|
|
157
|
+
...(parameters.length ? { parameters } : {}),
|
|
158
|
+
...(route.body
|
|
159
|
+
? { requestBody: { required: true, content: { 'application/json': { schema: toJsonSchema(route.body) } } } }
|
|
160
|
+
: {}),
|
|
161
|
+
responses: {
|
|
162
|
+
[status]: { description: 'Success', content: { 'application/json': { schema: successSchema } } },
|
|
163
|
+
'400': errorResponse,
|
|
164
|
+
'401': errorResponse,
|
|
165
|
+
'404': errorResponse,
|
|
166
|
+
...(route.path.endsWith('/dependencies') && route.method === 'post' ? { '409': errorResponse } : {}),
|
|
167
|
+
'500': errorResponse
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
openapi: '3.1.0',
|
|
174
|
+
info: { title: options.title ?? 'Critical Path API', version: options.version ?? '1.0.0' },
|
|
175
|
+
...(options.serverUrl ? { servers: [{ url: options.serverUrl }] } : {}),
|
|
176
|
+
paths
|
|
177
|
+
};
|
|
178
|
+
}
|