@critical-path/core 0.24.0 → 0.25.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.
@@ -4,8 +4,9 @@
4
4
  * Exposed from `@critical-path/core/schemas` (not the package root) so browser bundles of core do
5
5
  * not pull in zod. Create schemas omit server-assigned fields (`id`, `createdAt`, `updatedAt`, task
6
6
  * `key`); update schemas additionally make every field optional and omit the owning `projectId`.
7
- * Unknown keys are stripped rather than rejected, so older clients that send whole objects keep
8
- * working while protected fields are never written.
7
+ * Schemas are strict: unknown or server-assigned keys are rejected with a 400 rather than ignored.
8
+ * Identity (authors, reactors, uploaders, time-entry users) is never taken from payloads; it comes
9
+ * from the caller resolved by the server (see `CriticalPathEngine.withActor`).
9
10
  */
10
11
  import { z } from 'zod';
11
12
  import { ValidationError, type ValidationIssue } from '../domain/errors.js';
@@ -25,36 +26,41 @@ export function parsePayload<S extends z.ZodTypeAny>(schema: S, data: unknown):
25
26
  throw new ValidationError(`Invalid request body: ${summary}`, issues);
26
27
  }
27
28
 
29
+ /** Objects reject unknown keys so typos and protected fields surface as 400s. */
30
+ function strictObject<T extends z.ZodRawShape>(shape: T) {
31
+ return z.object(shape).strict();
32
+ }
33
+
28
34
  const isoString = z.string();
29
35
  const nonEmpty = z.string().min(1);
30
36
 
31
37
  export const SemanticStatusSchema = z.enum(['not_started', 'in_progress', 'completed', 'canceled']);
32
38
  export const AuthorTypeSchema = z.enum(['user', 'agent', 'system']);
33
39
 
34
- export const StatusDefinitionSchema = z.object({
40
+ export const StatusDefinitionSchema = strictObject({
35
41
  key: nonEmpty,
36
42
  label: z.string(),
37
43
  category: SemanticStatusSchema
38
44
  });
39
45
 
40
- export const WorkScheduleSchema = z.object({
46
+ export const WorkScheduleSchema = strictObject({
41
47
  id: z.string().optional(),
42
48
  name: z.string().optional(),
43
49
  timezone: z.string().optional(),
44
50
  defaultHoursPerDay: z.number().nonnegative().optional(),
45
51
  days: z.array(
46
- z.object({
52
+ strictObject({
47
53
  dayOfWeek: z.number().int().min(0).max(6),
48
54
  isWorkingDay: z.boolean(),
49
- hours: z.array(z.object({ start: z.string(), end: z.string() })).optional()
55
+ hours: z.array(strictObject({ start: z.string(), end: z.string() })).optional()
50
56
  })
51
57
  ),
52
58
  holidays: z
53
- .array(z.object({ date: z.string(), name: z.string().optional(), halfDay: z.boolean().optional() }))
59
+ .array(strictObject({ date: z.string(), name: z.string().optional(), halfDay: z.boolean().optional() }))
54
60
  .optional()
55
61
  });
56
62
 
57
- export const CustomFieldDefinitionSchema = z.object({
63
+ export const CustomFieldDefinitionSchema = strictObject({
58
64
  id: z.string(),
59
65
  key: nonEmpty,
60
66
  label: z.string(),
@@ -64,7 +70,7 @@ export const CustomFieldDefinitionSchema = z.object({
64
70
  defaultValue: z.unknown().optional()
65
71
  });
66
72
 
67
- export const TaskTypeDefinitionSchema = z.object({
73
+ export const TaskTypeDefinitionSchema = strictObject({
68
74
  key: nonEmpty,
69
75
  label: z.string(),
70
76
  description: z.string().optional(),
@@ -78,13 +84,13 @@ const metadata = z.record(z.unknown());
78
84
 
79
85
  // --- Workflows ---
80
86
 
81
- export const CreateWorkflowSchema = z.object({
87
+ export const CreateWorkflowSchema = strictObject({
82
88
  name: nonEmpty,
83
89
  description: z.string().optional(),
84
90
  statuses: z.array(StatusDefinitionSchema),
85
91
  transitions: z
86
92
  .array(
87
- z.object({
93
+ strictObject({
88
94
  id: z.string().optional(),
89
95
  name: z.string().optional(),
90
96
  fromStatusKey: z.string(),
@@ -100,7 +106,7 @@ export const UpdateWorkflowSchema = CreateWorkflowSchema.partial();
100
106
 
101
107
  // --- Projects ---
102
108
 
103
- export const CreateProjectSchema = z.object({
109
+ export const CreateProjectSchema = strictObject({
104
110
  key: z.string().optional(),
105
111
  name: nonEmpty,
106
112
  description: z.string().optional(),
@@ -111,7 +117,7 @@ export const CreateProjectSchema = z.object({
111
117
  taskTypes: z.array(TaskTypeDefinitionSchema).optional(),
112
118
  statusDefinitions: z.array(StatusDefinitionSchema).optional(),
113
119
  priorityDefinitions: z
114
- .array(z.object({ key: z.string(), label: z.string(), level: z.number().optional() }))
120
+ .array(strictObject({ key: z.string(), label: z.string(), level: z.number().optional() }))
115
121
  .optional(),
116
122
  customFieldDefinitions: z.array(CustomFieldDefinitionSchema).optional(),
117
123
  schedule: WorkScheduleSchema.optional(),
@@ -132,7 +138,7 @@ const taskFields = {
132
138
  assigneeId: z.string().optional(),
133
139
  assignees: z
134
140
  .array(
135
- z.object({
141
+ strictObject({
136
142
  id: z.string(),
137
143
  name: z.string().optional(),
138
144
  role: z.string().optional(),
@@ -169,7 +175,7 @@ const taskFields = {
169
175
  tags: z.array(z.string()).optional(),
170
176
  todos: z
171
177
  .array(
172
- z.object({
178
+ strictObject({
173
179
  id: z.string(),
174
180
  title: z.string(),
175
181
  completed: z.boolean(),
@@ -182,23 +188,13 @@ const taskFields = {
182
188
  parentId: z.string().optional()
183
189
  };
184
190
 
185
- export const CreateTaskSchema = z.object({ projectId: nonEmpty, ...taskFields });
191
+ export const CreateTaskSchema = strictObject({ projectId: nonEmpty, ...taskFields });
186
192
 
187
- /** Actor fields accepted on task updates; ignored when the server resolves the caller. */
188
- const actorClaimFields = {
189
- actorId: z.string().optional(),
190
- actorName: z.string().optional(),
191
- actorType: z.string().optional(),
192
- actor: z
193
- .object({ userId: z.string(), username: z.string().optional(), actorType: z.string().optional() })
194
- .optional()
195
- };
196
-
197
- export const UpdateTaskSchema = z.object(taskFields).partial().extend(actorClaimFields);
193
+ export const UpdateTaskSchema = strictObject(taskFields).partial();
198
194
 
199
195
  // --- Dependencies ---
200
196
 
201
- export const CreateDependencySchema = z.object({
197
+ export const CreateDependencySchema = strictObject({
202
198
  dependsOnTaskId: nonEmpty,
203
199
  type: z.enum(['blocking', 'blocked_by', 'relates_to']).default('blocking')
204
200
  });
@@ -218,12 +214,12 @@ const deliverableFields = {
218
214
  outputUrls: z.array(z.string()).optional(),
219
215
  customFields: customFields.optional()
220
216
  };
221
- export const CreateDeliverableSchema = z.object({ projectId: nonEmpty, ...deliverableFields });
222
- export const UpdateDeliverableSchema = z.object(deliverableFields).partial();
217
+ export const CreateDeliverableSchema = strictObject({ projectId: nonEmpty, ...deliverableFields });
218
+ export const UpdateDeliverableSchema = strictObject(deliverableFields).partial();
223
219
 
224
220
  // --- Teams ---
225
221
 
226
- export const CreateTeamSchema = z.object({
222
+ export const CreateTeamSchema = strictObject({
227
223
  name: nonEmpty,
228
224
  description: z.string().optional(),
229
225
  leaderId: z.string().optional(),
@@ -242,8 +238,8 @@ const containerFields = {
242
238
  type: z.string().optional(),
243
239
  color: z.string().optional()
244
240
  };
245
- export const CreateContainerSchema = z.object({ projectId: nonEmpty, ...containerFields });
246
- export const UpdateContainerSchema = z.object(containerFields).partial();
241
+ export const CreateContainerSchema = strictObject({ projectId: nonEmpty, ...containerFields });
242
+ export const UpdateContainerSchema = strictObject(containerFields).partial();
247
243
 
248
244
  // --- Iterations ---
249
245
 
@@ -255,18 +251,15 @@ const iterationFields = {
255
251
  endDate: isoString.optional(),
256
252
  status: z.enum(['planning', 'active', 'completed']).default('planning')
257
253
  };
258
- export const CreateIterationSchema = z.object({ projectId: nonEmpty, ...iterationFields });
254
+ export const CreateIterationSchema = strictObject({ projectId: nonEmpty, ...iterationFields });
259
255
  export const UpdateIterationSchema = z
260
256
  .object({ ...iterationFields, status: z.enum(['planning', 'active', 'completed']) })
261
257
  .partial();
262
258
 
263
259
  // --- Comments & Reactions ---
264
260
 
265
- export const CreateCommentSchema = z.object({
261
+ export const CreateCommentSchema = strictObject({
266
262
  taskId: nonEmpty,
267
- /** Required unless the server resolves the caller, in which case it is replaced. */
268
- authorId: z.string().optional(),
269
- authorType: AuthorTypeSchema.optional(),
270
263
  parentId: z.string().optional(),
271
264
  content: nonEmpty,
272
265
  mentions: z.array(z.string()).optional(),
@@ -276,10 +269,8 @@ export const UpdateCommentSchema = z
276
269
  .object({ content: nonEmpty, mentions: z.array(z.string()), metadata })
277
270
  .partial();
278
271
 
279
- export const CommentReactionSchema = z.object({
280
- emoji: nonEmpty,
281
- /** Required unless the server resolves the caller, in which case it is replaced. */
282
- userId: z.string().optional()
272
+ export const CommentReactionSchema = strictObject({
273
+ emoji: nonEmpty
283
274
  });
284
275
 
285
276
  // --- Attachments ---
@@ -288,14 +279,11 @@ const attachmentLinks = {
288
279
  taskId: z.string().optional(),
289
280
  projectId: z.string().optional(),
290
281
  commentId: z.string().optional(),
291
- /** Required unless the server resolves the caller, in which case it is replaced. */
292
- uploaderId: z.string().optional(),
293
- uploaderType: AuthorTypeSchema.optional(),
294
282
  artifactType: z.enum(['plan', 'spec', 'deliverable', 'review', 'general']).optional(),
295
283
  metadata: metadata.optional()
296
284
  };
297
285
 
298
- export const CreateAttachmentSchema = z.object({
286
+ export const CreateAttachmentSchema = strictObject({
299
287
  ...attachmentLinks,
300
288
  filename: nonEmpty,
301
289
  // Linked (rather than uploaded) files often have no known type or size.
@@ -305,7 +293,7 @@ export const CreateAttachmentSchema = z.object({
305
293
  storageKey: z.string().optional()
306
294
  });
307
295
 
308
- export const UploadAttachmentSchema = z.object({
296
+ export const UploadAttachmentSchema = strictObject({
309
297
  ...attachmentLinks,
310
298
  filename: nonEmpty,
311
299
  data: z.string(),
@@ -314,7 +302,7 @@ export const UploadAttachmentSchema = z.object({
314
302
  encoding: z.enum(['base64', 'utf-8', 'binary']).optional()
315
303
  });
316
304
 
317
- export const PresignAttachmentSchema = z.object({
305
+ export const PresignAttachmentSchema = strictObject({
318
306
  storageKey: nonEmpty,
319
307
  expiresInSeconds: z.number().int().positive().optional(),
320
308
  contentType: z.string().optional()
@@ -322,12 +310,11 @@ export const PresignAttachmentSchema = z.object({
322
310
 
323
311
  // --- Time Tracking ---
324
312
 
325
- export const LogTimeSchema = z.object({
313
+ export const LogTimeSchema = strictObject({
326
314
  taskId: nonEmpty,
327
315
  hours: z.number().positive(),
328
316
  isBillable: z.boolean().optional(),
329
317
  description: z.string().optional(),
330
- userId: z.string().optional(),
331
318
  loggedAt: isoString.optional()
332
319
  });
333
320
 
@@ -335,3 +322,28 @@ export type CreateTaskPayload = z.infer<typeof CreateTaskSchema>;
335
322
  export type UpdateTaskPayload = z.infer<typeof UpdateTaskSchema>;
336
323
  export type CreateProjectPayload = z.infer<typeof CreateProjectSchema>;
337
324
  export type UpdateProjectPayload = z.infer<typeof UpdateProjectSchema>;
325
+
326
+ /** Request body types (before defaults are applied), for typed API clients. */
327
+ export type CreateWorkflowBody = z.input<typeof CreateWorkflowSchema>;
328
+ export type UpdateWorkflowBody = z.input<typeof UpdateWorkflowSchema>;
329
+ export type CreateProjectBody = z.input<typeof CreateProjectSchema>;
330
+ export type UpdateProjectBody = z.input<typeof UpdateProjectSchema>;
331
+ export type CreateTaskBody = z.input<typeof CreateTaskSchema>;
332
+ export type UpdateTaskBody = z.input<typeof UpdateTaskSchema>;
333
+ export type CreateDependencyBody = z.input<typeof CreateDependencySchema>;
334
+ export type CreateDeliverableBody = z.input<typeof CreateDeliverableSchema>;
335
+ export type UpdateDeliverableBody = z.input<typeof UpdateDeliverableSchema>;
336
+ export type CreateTeamBody = z.input<typeof CreateTeamSchema>;
337
+ export type UpdateTeamBody = z.input<typeof UpdateTeamSchema>;
338
+ export type CreateContainerBody = z.input<typeof CreateContainerSchema>;
339
+ export type UpdateContainerBody = z.input<typeof UpdateContainerSchema>;
340
+ export type CreateIterationBody = z.input<typeof CreateIterationSchema>;
341
+ export type UpdateIterationBody = z.input<typeof UpdateIterationSchema>;
342
+ export type CreateCommentBody = z.input<typeof CreateCommentSchema>;
343
+ export type UpdateCommentBody = z.input<typeof UpdateCommentSchema>;
344
+ export type CommentReactionBody = z.input<typeof CommentReactionSchema>;
345
+ export type CreateAttachmentBody = z.input<typeof CreateAttachmentSchema>;
346
+ export type UploadAttachmentBody = z.input<typeof UploadAttachmentSchema>;
347
+ export type PresignAttachmentBody = z.input<typeof PresignAttachmentSchema>;
348
+ export type LogTimeBody = z.input<typeof LogTimeSchema>;
349
+
@@ -47,11 +47,13 @@ type SameKeys<Schema, Expected> = [Exclude<keyof Schema, keyof Expected>, Exclud
47
47
 
48
48
  type Infer<S extends z.ZodTypeAny> = z.infer<S>;
49
49
  type ServerAssigned = 'id' | 'createdAt' | 'updatedAt';
50
- type ActorClaims = 'actorId' | 'actorName' | 'actorType' | 'actor';
50
+ // Identity is resolved by the server (withActor), never accepted in payloads.
51
+ type CommentIdentity = 'authorId' | 'authorType';
52
+ type UploaderIdentity = 'uploaderId' | 'uploaderType';
51
53
 
52
54
  const keyChecks: true[] = [
53
55
  true as SameKeys<Infer<typeof CreateTaskSchema>, Omit<CreateTaskInput, 'key'>>,
54
- true as SameKeys<Omit<Infer<typeof UpdateTaskSchema>, ActorClaims>, Omit<Task, ServerAssigned | 'key' | 'projectId'>>,
56
+ true as SameKeys<Infer<typeof UpdateTaskSchema>, Omit<Task, ServerAssigned | 'key' | 'projectId'>>,
55
57
  // `workflow` is a denormalised copy resolved from `workflowId`, so it is not writable.
56
58
  true as SameKeys<Infer<typeof CreateProjectSchema>, Omit<CreateProjectInput, 'workflow'>>,
57
59
  true as SameKeys<Infer<typeof UpdateProjectSchema>, Omit<Project, ServerAssigned | 'workflow'>>,
@@ -63,10 +65,10 @@ const keyChecks: true[] = [
63
65
  true as SameKeys<Infer<typeof UpdateContainerSchema>, Omit<TaskContainer, ServerAssigned | 'projectId'>>,
64
66
  true as SameKeys<Infer<typeof CreateIterationSchema>, Omit<Iteration, 'id' | 'createdAt'>>,
65
67
  // Reactions are managed through the reactions endpoints.
66
- true as SameKeys<Infer<typeof CreateCommentSchema>, Omit<Comment, ServerAssigned | 'reactions'>>,
68
+ true as SameKeys<Infer<typeof CreateCommentSchema>, Omit<Comment, ServerAssigned | 'reactions' | CommentIdentity>>,
67
69
  true as SameKeys<Infer<typeof UpdateCommentSchema>, Pick<Comment, 'content' | 'mentions' | 'metadata'>>,
68
- true as SameKeys<Infer<typeof CreateAttachmentSchema>, CreateAttachmentInput>,
69
- true as SameKeys<Infer<typeof LogTimeSchema>, Omit<TimeEntry, 'id'>>
70
+ true as SameKeys<Infer<typeof CreateAttachmentSchema>, Omit<CreateAttachmentInput, UploaderIdentity>>,
71
+ true as SameKeys<Infer<typeof LogTimeSchema>, Omit<TimeEntry, 'id' | 'userId'>>
70
72
  ];
71
73
 
72
74
  describe('request schemas', () => {
@@ -74,16 +76,15 @@ describe('request schemas', () => {
74
76
  expect(keyChecks.every(Boolean)).toBe(true);
75
77
  });
76
78
 
77
- it('strip server-assigned and unknown fields', () => {
78
- const parsed = UpdateTaskSchema.parse({
79
- title: 'ok',
80
- id: 'hijack',
81
- projectId: 'other',
82
- createdAt: '1999-01-01',
83
- key: 'X-1',
84
- junk: true
85
- });
86
- expect(parsed).toEqual({ title: 'ok' });
79
+ it('reject server-assigned, identity and unknown fields', () => {
80
+ for (const extra of [{ id: 'x' }, { projectId: 'other' }, { createdAt: '1999-01-01' }, { key: 'X-1' }, { junk: true }, { actorId: 'ceo' }]) {
81
+ const result = UpdateTaskSchema.safeParse({ title: 'ok', ...extra });
82
+ expect(result.success, JSON.stringify(extra)).toBe(false);
83
+ }
84
+ expect(CreateCommentSchema.safeParse({ taskId: 't1', content: 'hi', authorId: 'ceo' }).success).toBe(false);
85
+ // Nested objects are strict too
86
+ expect(UpdateTaskSchema.safeParse({ todos: [{ id: '1', title: 'a', completed: false, extra: 1 }] }).success).toBe(false);
87
+ expect(UpdateTaskSchema.parse({ title: 'ok' })).toEqual({ title: 'ok' });
87
88
  });
88
89
 
89
90
  it('reject wrong types and out-of-range values', () => {