@critical-path/server 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.test.ts CHANGED
@@ -752,5 +752,83 @@ describe('@critical-path/server Router Tests', () => {
752
752
  expect((await handle(new Request('http://localhost/other/projects'))).status).toBe(404);
753
753
  });
754
754
  });
755
+
756
+ describe('request body validation', () => {
757
+ const send = (router: CriticalPathRouter, method: string, path: string, body: unknown, token?: string) =>
758
+ router.handleRequest(
759
+ new Request(`http://localhost:3000/api/critical-path${path}`, {
760
+ method,
761
+ headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
762
+ body: JSON.stringify(body)
763
+ })
764
+ );
765
+
766
+ it('ignores attempts to move tasks or rewrite server-assigned fields', async () => {
767
+ const router = new CriticalPathRouter();
768
+ const home = await router.engine.createProject({ key: 'HOME', name: 'Home' });
769
+ const other = await router.engine.createProject({ key: 'OTH', name: 'Other' });
770
+ const task = await router.engine.createTask({ projectId: home.id, title: 'Stay' });
771
+
772
+ const res = await send(router, 'PATCH', `/tasks/${task.id}`, {
773
+ title: 'Renamed',
774
+ projectId: other.id,
775
+ id: 'hijacked',
776
+ createdAt: '1999-01-01T00:00:00.000Z',
777
+ injected: '<script>'
778
+ });
779
+ expect(res.status).toBe(200);
780
+ const stored = await router.engine.getTask(task.id);
781
+ expect(stored?.title).toBe('Renamed');
782
+ expect(stored?.projectId).toBe(home.id);
783
+ expect(stored?.createdAt).toBe(task.createdAt);
784
+ expect((stored as any).injected).toBeUndefined();
785
+ });
786
+
787
+ it('returns 400 with field-level issues for invalid bodies', async () => {
788
+ const router = new CriticalPathRouter();
789
+ const project = await router.engine.createProject({ key: 'VAL', name: 'Validation' });
790
+
791
+ const res = await send(router, 'POST', '/tasks', { projectId: project.id, title: '', progress: 'half' });
792
+ expect(res.status).toBe(400);
793
+ const data = await res.json();
794
+ expect(data.issues.map((i: { path: string }) => i.path).sort()).toEqual(['progress', 'title']);
795
+
796
+ expect((await send(router, 'POST', '/projects', [])).status).toBe(400);
797
+ expect((await send(router, 'POST', '/iterations', { projectId: project.id, name: 'S1', status: 'later' })).status).toBe(400);
798
+ });
799
+
800
+ it('requires an author unless the caller is resolved, and path ids win over body ids', async () => {
801
+ const router = new CriticalPathRouter(undefined, {
802
+ getContext: (request) => {
803
+ const token = request.headers.get('Authorization')?.replace('Bearer ', '');
804
+ return token ? { userId: token } : null;
805
+ }
806
+ });
807
+ const project = await router.engine.createProject({ key: 'AUT', name: 'Authors' });
808
+ const task = await router.engine.createTask({ projectId: project.id, title: 'T' });
809
+ const decoy = await router.engine.createTask({ projectId: project.id, title: 'Decoy' });
810
+
811
+ const anonymous = await send(router, 'POST', `/tasks/${task.id}/comments`, { content: 'hi' });
812
+ expect(anonymous.status).toBe(400);
813
+ expect((await anonymous.json()).error).toContain('authorId');
814
+
815
+ const authed = await send(router, 'POST', `/tasks/${task.id}/comments`, { content: 'hi', taskId: decoy.id }, 'alice');
816
+ expect(authed.status).toBe(201);
817
+ const { comment } = await authed.json();
818
+ expect(comment.authorId).toBe('alice');
819
+ expect(comment.taskId).toBe(task.id);
820
+ });
821
+
822
+ it('keeps accepting actor claims from unauthenticated trusted callers', async () => {
823
+ const router = new CriticalPathRouter();
824
+ const project = await router.engine.createProject({ key: 'CLI', name: 'CLI' });
825
+ const task = await router.engine.createTask({ projectId: project.id, title: 'T' });
826
+
827
+ const res = await send(router, 'PATCH', `/tasks/${task.id}`, { isBlocked: true, actorId: 'agent-7', actorType: 'agent' });
828
+ expect(res.status).toBe(200);
829
+ const activities = await router.engine.store.getActivities({ taskId: task.id });
830
+ expect(activities.some((a) => a.actorId === 'agent-7')).toBe(true);
831
+ });
832
+ });
755
833
  });
756
834
 
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', 'userId'], 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
+ }