@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/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', 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);
@@ -763,7 +769,7 @@ describe('@critical-path/server Router Tests', () => {
763
769
  })
764
770
  );
765
771
 
766
- it('ignores attempts to move tasks or rewrite server-assigned fields', async () => {
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(200);
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('Renamed');
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('requires an author unless the caller is resolved, and path ids win over body ids', async () => {
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(400);
813
- expect((await anonymous.json()).error).toContain('authorId');
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('keeps accepting actor claims from unauthenticated trusted callers', async () => {
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: 'CLI', name: 'CLI' });
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 res = await send(router, 'PATCH', `/tasks/${task.id}`, { isBlocked: true, actorId: 'agent-7', actorType: 'agent' });
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 === 'agent-7')).toBe(true);
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', 'userId'], responseKey: 'comment' },
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 and
57
- * identity fields in request bodies (`actorId`, `authorId`, `userId`, ...) are ignored.
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
- /** CORS policy. Defaults to `{ origins: '*' }`; pass `false` to send no CORS headers. */
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 === undefined ? { origins: '*' } : 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
- const engine = context?.userId
174
- ? this.engine.withActor({ userId: context.userId, username: context.userName, actorType: context.actorType })
175
- : this.engine;
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({ ...body, authorId: this.requireIdentity(context, body.authorId, 'authorId') });
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({ ...body, uploaderId: this.requireIdentity(context, body.uploaderId, 'uploaderId') });
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.store.getActivities({ projectId, taskId });
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 userId = this.requireIdentity(context, body.userId, 'userId');
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
- let body: any = {};
552
- try {
553
- body = await this.readJson(request);
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, userId });
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({ ...body, authorId: this.requireIdentity(context, body.authorId, 'authorId') });
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({ ...body, uploaderId: this.requireIdentity(context, body.uploaderId, 'uploaderId') });
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({ ...body, uploaderId: this.requireIdentity(context, body.uploaderId, 'uploaderId') });
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.store.getTimeEntries(taskId);
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