@critical-path/server 0.16.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/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
 
@@ -53,8 +56,8 @@ export interface CriticalPathRouterOptions {
53
56
  exposeErrors?: boolean;
54
57
  /**
55
58
  * 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.
59
+ * When it returns a `userId`, all mutations in the request are attributed to that user;
60
+ * otherwise they are attributed to `ANONYMOUS_ACTOR`. Request bodies never carry identity.
58
61
  */
59
62
  getContext?: (request: Request) => RequestContext | null | undefined | Promise<RequestContext | null | undefined>;
60
63
  /** Reject requests with `401` unless `getContext` returns a `userId`. Defaults to `false`. */
@@ -64,7 +67,10 @@ export interface CriticalPathRouterOptions {
64
67
  * When omitted, everything up to the first `/critical-path` segment is stripped.
65
68
  */
66
69
  basePath?: string;
67
- /** CORS policy. Defaults to `{ origins: '*' }`; pass `false` to send no CORS headers. */
70
+ /**
71
+ * CORS policy. Defaults to `false` (no CORS headers, so only same-origin browsers can call the
72
+ * API). Set `{ origins: [...] }` to allow specific sites, or `{ origins: '*' }` for any site.
73
+ */
68
74
  cors?: CorsOptions | false;
69
75
  }
70
76
 
@@ -92,7 +98,7 @@ export class CriticalPathRouter {
92
98
  this.getContext = options.getContext;
93
99
  this.requireAuth = options.requireAuth ?? false;
94
100
  this.basePath = options.basePath ? '/' + options.basePath.replace(/^\/+|\/+$/g, '') : undefined;
95
- this.cors = options.cors === undefined ? { origins: '*' } : options.cors;
101
+ this.cors = options.cors ?? false;
96
102
  if (this.cors && this.cors.origins === '*' && this.cors.credentials) {
97
103
  throw new Error('CORS credentials cannot be combined with origins "*"; list the allowed origins instead.');
98
104
  }
@@ -170,9 +176,12 @@ export class CriticalPathRouter {
170
176
  if (this.requireAuth && !context?.userId) {
171
177
  return this.jsonResponse({ error: 'Authentication required' }, 401);
172
178
  }
173
- const engine = context?.userId
174
- ? this.engine.withActor({ userId: context.userId, username: context.userName, actorType: context.actorType })
175
- : this.engine;
179
+ // Every request runs as an actor, so authorship never comes from request bodies.
180
+ const engine = this.engine.withActor(
181
+ context?.userId
182
+ ? { userId: context.userId, username: context.userName, actorType: context.actorType }
183
+ : ANONYMOUS_ACTOR
184
+ );
176
185
 
177
186
  try {
178
187
  // OpenAPI contract (behind the same auth as every other route)
@@ -304,7 +313,7 @@ export class CriticalPathRouter {
304
313
  }
305
314
  if (method === 'POST') {
306
315
  const body = await this.readBody(request, schemas.CreateCommentSchema, { taskId });
307
- const comment = await engine.addComment({ ...body, authorId: this.requireIdentity(context, body.authorId, 'authorId') });
316
+ const comment = await engine.addComment(body);
308
317
  return this.jsonResponse({ comment }, 201);
309
318
  }
310
319
  } else if (subResource === 'attachments') {
@@ -314,7 +323,7 @@ export class CriticalPathRouter {
314
323
  }
315
324
  if (method === 'POST') {
316
325
  const body = await this.readBody(request, schemas.CreateAttachmentSchema, { taskId });
317
- const attachment = await engine.createAttachment({ ...body, uploaderId: this.requireIdentity(context, body.uploaderId, 'uploaderId') });
326
+ const attachment = await engine.createAttachment(body);
318
327
  return this.jsonResponse({ attachment }, 201);
319
328
  }
320
329
  } else if (subResource === 'dependencies') {
@@ -542,24 +551,16 @@ export class CriticalPathRouter {
542
551
  if (commentId && subResource === 'reactions') {
543
552
  if (method === 'POST') {
544
553
  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 });
554
+ const comment = await engine.addCommentReaction(commentId, body);
547
555
  if (!comment) return this.jsonResponse({ error: 'Comment not found' }, 404);
548
556
  return this.jsonResponse({ comment }, 200);
549
557
  }
550
558
  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);
559
+ const emoji = url.searchParams.get('emoji');
560
+ if (!emoji) {
561
+ return this.jsonResponse({ error: 'emoji query parameter is required' }, 400);
561
562
  }
562
- const comment = await engine.removeCommentReaction(commentId, { emoji, userId });
563
+ const comment = await engine.removeCommentReaction(commentId, { emoji });
563
564
  if (!comment) return this.jsonResponse({ error: 'Comment not found' }, 404);
564
565
  return this.jsonResponse({ comment }, 200);
565
566
  }
@@ -572,7 +573,7 @@ export class CriticalPathRouter {
572
573
  }
573
574
  if (method === 'POST') {
574
575
  const body = await this.readBody(request, schemas.CreateCommentSchema);
575
- const comment = await engine.addComment({ ...body, authorId: this.requireIdentity(context, body.authorId, 'authorId') });
576
+ const comment = await engine.addComment(body);
576
577
  return this.jsonResponse({ comment }, 201);
577
578
  }
578
579
  } else {
@@ -607,7 +608,7 @@ export class CriticalPathRouter {
607
608
  } else if (attachmentId === 'upload') {
608
609
  if (method === 'POST') {
609
610
  const body = await this.readBody(request, schemas.UploadAttachmentSchema);
610
- const attachment = await engine.uploadAttachmentFile({ ...body, uploaderId: this.requireIdentity(context, body.uploaderId, 'uploaderId') });
611
+ const attachment = await engine.uploadAttachmentFile(body);
611
612
  return this.jsonResponse({ attachment }, 201);
612
613
  }
613
614
  } else if (!attachmentId) {
@@ -620,7 +621,7 @@ export class CriticalPathRouter {
620
621
  }
621
622
  if (method === 'POST') {
622
623
  const body = await this.readBody(request, schemas.CreateAttachmentSchema);
623
- const attachment = await engine.createAttachment({ ...body, uploaderId: this.requireIdentity(context, body.uploaderId, 'uploaderId') });
624
+ const attachment = await engine.createAttachment(body);
624
625
  return this.jsonResponse({ attachment }, 201);
625
626
  }
626
627
  } else {
@@ -724,16 +725,6 @@ export class CriticalPathRouter {
724
725
  return schemas.parsePayload(schema as any, { ...body, ...overrides });
725
726
  }
726
727
 
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
728
  /** The API root as seen by this request, e.g. `https://host/api/critical-path`. */
738
729
  private openApiServerUrl(url: URL, subpath: string): string {
739
730
  const root = url.pathname.slice(0, url.pathname.length - subpath.length).replace(/\/+$/, '');