@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/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', authorId: 'u1', authorType: 'user' })
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: '❤️', userId: 'u2' })
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 userId)
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(`http://localhost:3000/api/critical-path/comments/${comment.id}/reactions`, {
225
- method: 'DELETE',
226
- headers: { 'Content-Type': 'application/json' },
227
- body: JSON.stringify({ emoji: '❤️', userId: 'u2' })
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 router = new CriticalPathRouter();
488
- const res = await router.handleRequest(new Request(`${base}/tasks`, { method: 'OPTIONS' }));
489
- expect(res.status).toBe(204);
490
- expect(res.headers.get('Access-Control-Allow-Methods')).toContain('PATCH');
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 without allowing id or timestamp overrides', async () => {
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
- const res = await patch(proj.id, { name: 'After', id: 'hijacked', createdAt: '1999-01-01T00:00:00.000Z' });
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 and ignores identity in bodies', async () => {
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', actorId: 'ceo', actorName: 'CEO' })
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', authorId: 'ceo' })
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', authorId: 'spoofed' })
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
@@ -2,3 +2,4 @@ export * from './router.js';
2
2
  export * from './adapters/next.js';
3
3
  export * from './adapters/sveltekit.js';
4
4
  export * from './adapters/universal.js';
5
+ export * from './openapi.js';
@@ -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
+ }