@critical-path/server 0.16.0 → 0.18.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 +63 -0
- package/README.md +6 -5
- package/dist/index.test.js +77 -37
- package/dist/index.test.js.map +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +2 -1
- package/dist/openapi.js.map +1 -1
- package/dist/router.d.ts +16 -8
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +27 -35
- package/dist/router.js.map +1 -1
- package/package.json +2 -2
- package/src/index.test.ts +98 -37
- package/src/openapi.ts +2 -1
- package/src/router.ts +40 -37
package/src/index.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { CriticalPathRouter } from './router.js';
|
|
|
3
3
|
import { createNextHandler } from './adapters/next.js';
|
|
4
4
|
import { createSvelteKitHandler } from './adapters/sveltekit.js';
|
|
5
5
|
import { createUniversalHandler } from './adapters/universal.js';
|
|
6
|
-
import { InMemoryStore } from '@critical-path/core';
|
|
6
|
+
import { InMemoryStore, createRolePolicy } from '@critical-path/core';
|
|
7
7
|
|
|
8
8
|
describe('@critical-path/server Router Tests', () => {
|
|
9
9
|
it('handles project creation and retrieval over HTTP Fetch Requests', async () => {
|
|
@@ -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);
|
|
@@ -763,7 +769,7 @@ describe('@critical-path/server Router Tests', () => {
|
|
|
763
769
|
})
|
|
764
770
|
);
|
|
765
771
|
|
|
766
|
-
it('
|
|
772
|
+
it('rejects attempts to move tasks or rewrite server-assigned fields', async () => {
|
|
767
773
|
const router = new CriticalPathRouter();
|
|
768
774
|
const home = await router.engine.createProject({ key: 'HOME', name: 'Home' });
|
|
769
775
|
const other = await router.engine.createProject({ key: 'OTH', name: 'Other' });
|
|
@@ -776,12 +782,14 @@ describe('@critical-path/server Router Tests', () => {
|
|
|
776
782
|
createdAt: '1999-01-01T00:00:00.000Z',
|
|
777
783
|
injected: '<script>'
|
|
778
784
|
});
|
|
779
|
-
expect(res.status).toBe(
|
|
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
|
+
|
|
780
790
|
const stored = await router.engine.getTask(task.id);
|
|
781
|
-
expect(stored?.title).toBe('
|
|
791
|
+
expect(stored?.title).toBe('Stay');
|
|
782
792
|
expect(stored?.projectId).toBe(home.id);
|
|
783
|
-
expect(stored?.createdAt).toBe(task.createdAt);
|
|
784
|
-
expect((stored as any).injected).toBeUndefined();
|
|
785
793
|
});
|
|
786
794
|
|
|
787
795
|
it('returns 400 with field-level issues for invalid bodies', async () => {
|
|
@@ -797,7 +805,7 @@ describe('@critical-path/server Router Tests', () => {
|
|
|
797
805
|
expect((await send(router, 'POST', '/iterations', { projectId: project.id, name: 'S1', status: 'later' })).status).toBe(400);
|
|
798
806
|
});
|
|
799
807
|
|
|
800
|
-
it('
|
|
808
|
+
it('takes authorship from the caller (or anonymous), never the body, and path ids win', async () => {
|
|
801
809
|
const router = new CriticalPathRouter(undefined, {
|
|
802
810
|
getContext: (request) => {
|
|
803
811
|
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
|
|
@@ -809,8 +817,11 @@ describe('@critical-path/server Router Tests', () => {
|
|
|
809
817
|
const decoy = await router.engine.createTask({ projectId: project.id, title: 'Decoy' });
|
|
810
818
|
|
|
811
819
|
const anonymous = await send(router, 'POST', `/tasks/${task.id}/comments`, { content: 'hi' });
|
|
812
|
-
expect(anonymous.status).toBe(
|
|
813
|
-
expect((await anonymous.json()).
|
|
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);
|
|
814
825
|
|
|
815
826
|
const authed = await send(router, 'POST', `/tasks/${task.id}/comments`, { content: 'hi', taskId: decoy.id }, 'alice');
|
|
816
827
|
expect(authed.status).toBe(201);
|
|
@@ -819,15 +830,65 @@ describe('@critical-path/server Router Tests', () => {
|
|
|
819
830
|
expect(comment.taskId).toBe(task.id);
|
|
820
831
|
});
|
|
821
832
|
|
|
822
|
-
it('
|
|
833
|
+
it('attributes unauthenticated writes to the anonymous actor and rejects actor claims', async () => {
|
|
823
834
|
const router = new CriticalPathRouter();
|
|
824
|
-
const project = await router.engine.createProject({ key: '
|
|
835
|
+
const project = await router.engine.createProject({ key: 'ANO', name: 'Anonymous' });
|
|
825
836
|
const task = await router.engine.createTask({ projectId: project.id, title: 'T' });
|
|
826
837
|
|
|
827
|
-
const
|
|
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 });
|
|
828
842
|
expect(res.status).toBe(200);
|
|
829
843
|
const activities = await router.engine.store.getActivities({ taskId: task.id });
|
|
830
|
-
expect(activities.some((a) => a.actorId === '
|
|
844
|
+
expect(activities.some((a) => a.actorId === 'anonymous')).toBe(true);
|
|
845
|
+
});
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
describe('authorization and tenancy over HTTP', () => {
|
|
849
|
+
const users: Record<string, { userId: string; tenantId: string; roles?: string[] }> = {
|
|
850
|
+
alice: { userId: 'alice', tenantId: 'acme' },
|
|
851
|
+
vic: { userId: 'vic', tenantId: 'acme' },
|
|
852
|
+
gus: { userId: 'gus', tenantId: 'globex' }
|
|
853
|
+
};
|
|
854
|
+
const router = new CriticalPathRouter(
|
|
855
|
+
{ authorize: createRolePolicy() },
|
|
856
|
+
{
|
|
857
|
+
requireAuth: true,
|
|
858
|
+
getContext: (request) => users[request.headers.get('Authorization')?.replace('Bearer ', '') ?? ''] ?? null
|
|
859
|
+
}
|
|
860
|
+
);
|
|
861
|
+
const call = (token: string, method: string, path: string, body?: unknown) =>
|
|
862
|
+
router.handleRequest(
|
|
863
|
+
new Request(`http://localhost/api/critical-path${path}`, {
|
|
864
|
+
method,
|
|
865
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
866
|
+
...(body ? { body: JSON.stringify(body) } : {})
|
|
867
|
+
})
|
|
868
|
+
);
|
|
869
|
+
|
|
870
|
+
it('enforces roles with 403 and hides other tenants with 404', async () => {
|
|
871
|
+
const created = await call('alice', 'POST', '/projects', {
|
|
872
|
+
name: 'Acme roadmap',
|
|
873
|
+
members: [{ userId: 'vic', role: 'viewer' }]
|
|
874
|
+
});
|
|
875
|
+
expect(created.status).toBe(201);
|
|
876
|
+
const { project } = await created.json();
|
|
877
|
+
expect(project.members).toEqual(expect.arrayContaining([{ userId: 'alice', role: 'admin' }]));
|
|
878
|
+
|
|
879
|
+
const viewerWrite = await call('vic', 'POST', '/tasks', { projectId: project.id, title: 'Nope' });
|
|
880
|
+
expect(viewerWrite.status).toBe(403);
|
|
881
|
+
|
|
882
|
+
const viewerRead = await call('vic', 'GET', `/projects/${project.id}`);
|
|
883
|
+
expect(viewerRead.status).toBe(200);
|
|
884
|
+
|
|
885
|
+
const otherTenant = await call('gus', 'GET', `/projects/${project.id}`);
|
|
886
|
+
expect(otherTenant.status).toBe(404);
|
|
887
|
+
const otherTenantList = await (await call('gus', 'GET', '/projects')).json();
|
|
888
|
+
expect(otherTenantList.projects).toHaveLength(0);
|
|
889
|
+
|
|
890
|
+
const tenantInBody = await call('alice', 'PATCH', `/projects/${project.id}`, { tenantId: 'globex' });
|
|
891
|
+
expect(tenantInBody.status).toBe(400);
|
|
831
892
|
});
|
|
832
893
|
});
|
|
833
894
|
});
|
package/src/openapi.ts
CHANGED
|
@@ -83,7 +83,7 @@ export const ROUTES: RouteDoc[] = [
|
|
|
83
83
|
{ method: 'patch', path: '/comments/{commentId}', summary: 'Update a comment', tag: 'Comments', body: schemas.UpdateCommentSchema, responseKey: 'comment' },
|
|
84
84
|
{ method: 'delete', path: '/comments/{commentId}', summary: 'Delete a comment', tag: 'Comments' },
|
|
85
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'
|
|
86
|
+
{ method: 'delete', path: '/comments/{commentId}/reactions', summary: 'Remove a reaction', tag: 'Comments', query: ['emoji!'], responseKey: 'comment' },
|
|
87
87
|
|
|
88
88
|
{ method: 'get', path: '/attachments', summary: 'List attachments', tag: 'Attachments', query: ['taskId', 'projectId', 'commentId'], responseKey: 'attachments' },
|
|
89
89
|
{ method: 'post', path: '/attachments', summary: 'Register an attachment', tag: 'Attachments', body: schemas.CreateAttachmentSchema, responseKey: 'attachment', status: 201 },
|
|
@@ -162,6 +162,7 @@ export function buildOpenApiDocument(options: OpenApiOptions = {}): Record<strin
|
|
|
162
162
|
[status]: { description: 'Success', content: { 'application/json': { schema: successSchema } } },
|
|
163
163
|
'400': errorResponse,
|
|
164
164
|
'401': errorResponse,
|
|
165
|
+
'403': errorResponse,
|
|
165
166
|
'404': errorResponse,
|
|
166
167
|
...(route.path.endsWith('/dependencies') && route.method === 'post' ? { '409': errorResponse } : {}),
|
|
167
168
|
'500': errorResponse
|
package/src/router.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { CriticalPathEngine, type AuthorType, type CriticalPathConfig } from '@c
|
|
|
2
2
|
import * as schemas from '@critical-path/core/schemas';
|
|
3
3
|
import { buildOpenApiDocument } from './openapi.js';
|
|
4
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 });
|
|
7
|
+
|
|
5
8
|
const CORS_ALLOW_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
|
6
9
|
const DEFAULT_CORS_ALLOW_HEADERS = ['Content-Type', 'Authorization'];
|
|
7
10
|
|
|
@@ -14,6 +17,10 @@ export interface RequestContext {
|
|
|
14
17
|
userId?: string;
|
|
15
18
|
userName?: string;
|
|
16
19
|
actorType?: AuthorType;
|
|
20
|
+
/** Tenant the caller belongs to; scopes projects, workflows and teams (see `Actor.tenantId`). */
|
|
21
|
+
tenantId?: string;
|
|
22
|
+
/** Workspace roles, e.g. `['admin']`, interpreted by the engine's `authorize` policy. */
|
|
23
|
+
roles?: string[];
|
|
17
24
|
[key: string]: unknown;
|
|
18
25
|
}
|
|
19
26
|
|
|
@@ -53,8 +60,8 @@ export interface CriticalPathRouterOptions {
|
|
|
53
60
|
exposeErrors?: boolean;
|
|
54
61
|
/**
|
|
55
62
|
* Resolves the caller's identity from the request (session cookie, bearer token, etc.).
|
|
56
|
-
* When it returns a `userId`, all mutations in the request are attributed to that user
|
|
57
|
-
*
|
|
63
|
+
* When it returns a `userId`, all mutations in the request are attributed to that user;
|
|
64
|
+
* otherwise they are attributed to `ANONYMOUS_ACTOR`. Request bodies never carry identity.
|
|
58
65
|
*/
|
|
59
66
|
getContext?: (request: Request) => RequestContext | null | undefined | Promise<RequestContext | null | undefined>;
|
|
60
67
|
/** Reject requests with `401` unless `getContext` returns a `userId`. Defaults to `false`. */
|
|
@@ -64,7 +71,10 @@ export interface CriticalPathRouterOptions {
|
|
|
64
71
|
* When omitted, everything up to the first `/critical-path` segment is stripped.
|
|
65
72
|
*/
|
|
66
73
|
basePath?: string;
|
|
67
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* CORS policy. Defaults to `false` (no CORS headers, so only same-origin browsers can call the
|
|
76
|
+
* API). Set `{ origins: [...] }` to allow specific sites, or `{ origins: '*' }` for any site.
|
|
77
|
+
*/
|
|
68
78
|
cors?: CorsOptions | false;
|
|
69
79
|
}
|
|
70
80
|
|
|
@@ -92,7 +102,7 @@ export class CriticalPathRouter {
|
|
|
92
102
|
this.getContext = options.getContext;
|
|
93
103
|
this.requireAuth = options.requireAuth ?? false;
|
|
94
104
|
this.basePath = options.basePath ? '/' + options.basePath.replace(/^\/+|\/+$/g, '') : undefined;
|
|
95
|
-
this.cors = options.cors
|
|
105
|
+
this.cors = options.cors ?? false;
|
|
96
106
|
if (this.cors && this.cors.origins === '*' && this.cors.credentials) {
|
|
97
107
|
throw new Error('CORS credentials cannot be combined with origins "*"; list the allowed origins instead.');
|
|
98
108
|
}
|
|
@@ -170,9 +180,18 @@ export class CriticalPathRouter {
|
|
|
170
180
|
if (this.requireAuth && !context?.userId) {
|
|
171
181
|
return this.jsonResponse({ error: 'Authentication required' }, 401);
|
|
172
182
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
183
|
+
// Every request runs as an actor, so authorship never comes from request bodies.
|
|
184
|
+
const engine = this.engine.withActor(
|
|
185
|
+
context?.userId
|
|
186
|
+
? {
|
|
187
|
+
userId: context.userId,
|
|
188
|
+
username: context.userName,
|
|
189
|
+
actorType: context.actorType,
|
|
190
|
+
tenantId: context.tenantId,
|
|
191
|
+
roles: context.roles
|
|
192
|
+
}
|
|
193
|
+
: { ...ANONYMOUS_ACTOR, tenantId: context?.tenantId }
|
|
194
|
+
);
|
|
176
195
|
|
|
177
196
|
try {
|
|
178
197
|
// OpenAPI contract (behind the same auth as every other route)
|
|
@@ -304,7 +323,7 @@ export class CriticalPathRouter {
|
|
|
304
323
|
}
|
|
305
324
|
if (method === 'POST') {
|
|
306
325
|
const body = await this.readBody(request, schemas.CreateCommentSchema, { taskId });
|
|
307
|
-
const comment = await engine.addComment(
|
|
326
|
+
const comment = await engine.addComment(body);
|
|
308
327
|
return this.jsonResponse({ comment }, 201);
|
|
309
328
|
}
|
|
310
329
|
} else if (subResource === 'attachments') {
|
|
@@ -314,7 +333,7 @@ export class CriticalPathRouter {
|
|
|
314
333
|
}
|
|
315
334
|
if (method === 'POST') {
|
|
316
335
|
const body = await this.readBody(request, schemas.CreateAttachmentSchema, { taskId });
|
|
317
|
-
const attachment = await engine.createAttachment(
|
|
336
|
+
const attachment = await engine.createAttachment(body);
|
|
318
337
|
return this.jsonResponse({ attachment }, 201);
|
|
319
338
|
}
|
|
320
339
|
} else if (subResource === 'dependencies') {
|
|
@@ -530,7 +549,7 @@ export class CriticalPathRouter {
|
|
|
530
549
|
if (method === 'GET') {
|
|
531
550
|
const projectId = url.searchParams.get('projectId') || undefined;
|
|
532
551
|
const taskId = url.searchParams.get('taskId') || undefined;
|
|
533
|
-
const activities = await engine.
|
|
552
|
+
const activities = await engine.getActivities({ projectId, taskId });
|
|
534
553
|
return this.jsonResponse({ activities });
|
|
535
554
|
}
|
|
536
555
|
}
|
|
@@ -542,24 +561,16 @@ export class CriticalPathRouter {
|
|
|
542
561
|
if (commentId && subResource === 'reactions') {
|
|
543
562
|
if (method === 'POST') {
|
|
544
563
|
const body = await this.readBody(request, schemas.CommentReactionSchema);
|
|
545
|
-
const
|
|
546
|
-
const comment = await engine.addCommentReaction(commentId, { emoji: body.emoji, userId });
|
|
564
|
+
const comment = await engine.addCommentReaction(commentId, body);
|
|
547
565
|
if (!comment) return this.jsonResponse({ error: 'Comment not found' }, 404);
|
|
548
566
|
return this.jsonResponse({ comment }, 200);
|
|
549
567
|
}
|
|
550
568
|
if (method === 'DELETE') {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
} catch {
|
|
555
|
-
// Body may be empty on DELETE, fallback to searchParams
|
|
556
|
-
}
|
|
557
|
-
const emoji = body.emoji || url.searchParams.get('emoji');
|
|
558
|
-
const userId = context?.userId ?? (body.userId || url.searchParams.get('userId'));
|
|
559
|
-
if (!emoji || !userId) {
|
|
560
|
-
return this.jsonResponse({ error: 'emoji and userId are required' }, 400);
|
|
569
|
+
const emoji = url.searchParams.get('emoji');
|
|
570
|
+
if (!emoji) {
|
|
571
|
+
return this.jsonResponse({ error: 'emoji query parameter is required' }, 400);
|
|
561
572
|
}
|
|
562
|
-
const comment = await engine.removeCommentReaction(commentId, { emoji
|
|
573
|
+
const comment = await engine.removeCommentReaction(commentId, { emoji });
|
|
563
574
|
if (!comment) return this.jsonResponse({ error: 'Comment not found' }, 404);
|
|
564
575
|
return this.jsonResponse({ comment }, 200);
|
|
565
576
|
}
|
|
@@ -572,7 +583,7 @@ export class CriticalPathRouter {
|
|
|
572
583
|
}
|
|
573
584
|
if (method === 'POST') {
|
|
574
585
|
const body = await this.readBody(request, schemas.CreateCommentSchema);
|
|
575
|
-
const comment = await engine.addComment(
|
|
586
|
+
const comment = await engine.addComment(body);
|
|
576
587
|
return this.jsonResponse({ comment }, 201);
|
|
577
588
|
}
|
|
578
589
|
} else {
|
|
@@ -607,7 +618,7 @@ export class CriticalPathRouter {
|
|
|
607
618
|
} else if (attachmentId === 'upload') {
|
|
608
619
|
if (method === 'POST') {
|
|
609
620
|
const body = await this.readBody(request, schemas.UploadAttachmentSchema);
|
|
610
|
-
const attachment = await engine.uploadAttachmentFile(
|
|
621
|
+
const attachment = await engine.uploadAttachmentFile(body);
|
|
611
622
|
return this.jsonResponse({ attachment }, 201);
|
|
612
623
|
}
|
|
613
624
|
} else if (!attachmentId) {
|
|
@@ -620,7 +631,7 @@ export class CriticalPathRouter {
|
|
|
620
631
|
}
|
|
621
632
|
if (method === 'POST') {
|
|
622
633
|
const body = await this.readBody(request, schemas.CreateAttachmentSchema);
|
|
623
|
-
const attachment = await engine.createAttachment(
|
|
634
|
+
const attachment = await engine.createAttachment(body);
|
|
624
635
|
return this.jsonResponse({ attachment }, 201);
|
|
625
636
|
}
|
|
626
637
|
} else {
|
|
@@ -642,7 +653,7 @@ export class CriticalPathRouter {
|
|
|
642
653
|
if (method === 'GET') {
|
|
643
654
|
const taskId = url.searchParams.get('taskId');
|
|
644
655
|
if (!taskId) return this.jsonResponse({ error: 'taskId parameter required' }, 400);
|
|
645
|
-
const entries = await engine.
|
|
656
|
+
const entries = await engine.getTimeEntries(taskId);
|
|
646
657
|
return this.jsonResponse({ timeEntries: entries });
|
|
647
658
|
}
|
|
648
659
|
if (method === 'POST') {
|
|
@@ -724,16 +735,6 @@ export class CriticalPathRouter {
|
|
|
724
735
|
return schemas.parsePayload(schema as any, { ...body, ...overrides });
|
|
725
736
|
}
|
|
726
737
|
|
|
727
|
-
/**
|
|
728
|
-
* Returns the identity to record for a write: the resolved caller when there is one, otherwise
|
|
729
|
-
* the value supplied in the body. Throws a 400 when neither is present.
|
|
730
|
-
*/
|
|
731
|
-
private requireIdentity(context: RequestContext | null | undefined, claimed: string | undefined, field: string): string {
|
|
732
|
-
const identity = context?.userId ?? claimed;
|
|
733
|
-
if (!identity) throw new BadRequestError(`${field} is required`);
|
|
734
|
-
return identity;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
738
|
/** The API root as seen by this request, e.g. `https://host/api/critical-path`. */
|
|
738
739
|
private openApiServerUrl(url: URL, subpath: string): string {
|
|
739
740
|
const root = url.pathname.slice(0, url.pathname.length - subpath.length).replace(/\/+$/, '');
|
|
@@ -766,6 +767,8 @@ export class CriticalPathRouter {
|
|
|
766
767
|
return this.jsonResponse({ error: e.message, cyclePath: e.cyclePath }, 409);
|
|
767
768
|
case 'NotFoundError':
|
|
768
769
|
return this.jsonResponse({ error: e.message }, 404);
|
|
770
|
+
case 'ForbiddenError':
|
|
771
|
+
return this.jsonResponse({ error: e.message }, 403);
|
|
769
772
|
}
|
|
770
773
|
|
|
771
774
|
// Unexpected errors may carry internals (SQL, URLs, config details), so they are only
|