@critical-path/core 0.22.0 → 0.24.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.
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ Actor,
2
3
  CriticalPathConfig,
3
4
  Project,
4
5
  Task,
@@ -98,6 +99,8 @@ export class CriticalPathEngine {
98
99
  public readonly plugins: PluginRegistry;
99
100
  public readonly events: DomainEventBus;
100
101
  public readonly ready: Promise<void> = Promise.resolve();
102
+ /** The identity mutations are attributed to. Set only on views returned by `withActor`. */
103
+ public readonly actor?: Actor;
101
104
 
102
105
  constructor(config: CriticalPathConfig = {}) {
103
106
  this.config = config;
@@ -120,6 +123,23 @@ export class CriticalPathEngine {
120
123
  }
121
124
  }
122
125
 
126
+ /**
127
+ * Returns a view of this engine that attributes every mutation to `actor`: activity log
128
+ * entries, comment authors, reactions, time entries, attachment uploaders and task reporters.
129
+ * Identity fields supplied in payloads are ignored on the view, so callers cannot claim to be
130
+ * someone else. The view shares the store, plugins and event bus, and does not modify this engine,
131
+ * so it is safe to create one per request.
132
+ */
133
+ withActor(actor: Actor): CriticalPathEngine {
134
+ const scoped = Object.create(this) as CriticalPathEngine;
135
+ Object.defineProperty(scoped, 'actor', { value: Object.freeze({ ...actor }), enumerable: true });
136
+ return scoped;
137
+ }
138
+
139
+ private actorIdOr(fallback: string): string {
140
+ return this.actor?.userId ?? fallback;
141
+ }
142
+
123
143
  private async seedInitialData(data: NonNullable<CriticalPathConfig['initialData']>): Promise<void> {
124
144
  if (data.workflows) {
125
145
  for (const wf of data.workflows) {
@@ -182,7 +202,7 @@ export class CriticalPathEngine {
182
202
  await this.events.publish(event);
183
203
 
184
204
  await this.store.logActivity({
185
- actorId: 'system',
205
+ actorId: this.actorIdOr('system'),
186
206
  action: 'workflow.created',
187
207
  details: { name: created.name }
188
208
  });
@@ -208,7 +228,7 @@ export class CriticalPathEngine {
208
228
  await this.events.publish(event);
209
229
 
210
230
  await this.store.logActivity({
211
- actorId: 'system',
231
+ actorId: this.actorIdOr('system'),
212
232
  action: 'workflow.updated',
213
233
  details: { name: updated.name }
214
234
  });
@@ -235,7 +255,7 @@ export class CriticalPathEngine {
235
255
  await this.events.publish(event);
236
256
 
237
257
  await this.store.logActivity({
238
- actorId: 'system',
258
+ actorId: this.actorIdOr('system'),
239
259
  action: 'workflow.deleted',
240
260
  details: { name: existing.name }
241
261
  });
@@ -306,7 +326,7 @@ export class CriticalPathEngine {
306
326
 
307
327
  await this.store.logActivity({
308
328
  projectId: created.id,
309
- actorId: project.ownerId || 'system',
329
+ actorId: this.actorIdOr(project.ownerId || 'system'),
310
330
  action: 'project.created',
311
331
  details: { name: created.name, key: created.key }
312
332
  });
@@ -333,7 +353,7 @@ export class CriticalPathEngine {
333
353
 
334
354
  await this.store.logActivity({
335
355
  projectId: updated.id,
336
- actorId: 'system',
356
+ actorId: this.actorIdOr('system'),
337
357
  action: 'project.updated',
338
358
  details: { name: updated.name }
339
359
  });
@@ -373,7 +393,7 @@ export class CriticalPathEngine {
373
393
 
374
394
  await this.store.logActivity({
375
395
  projectId: id,
376
- actorId: 'system',
396
+ actorId: this.actorIdOr('system'),
377
397
  action: 'project.deleted',
378
398
  details: { name: existing.name, deletedTaskCount: deletedTaskIds.length }
379
399
  });
@@ -392,6 +412,9 @@ export class CriticalPathEngine {
392
412
  }
393
413
 
394
414
  async createTask(taskInput: CreateTaskInput): Promise<Task> {
415
+ if (this.actor && !taskInput.reporterId) {
416
+ taskInput = { ...taskInput, reporterId: this.actor.userId };
417
+ }
395
418
  const processedInput = await this.plugins.runBeforeTaskCreate(taskInput);
396
419
  const projectId = processedInput.projectId || taskInput.projectId;
397
420
  const project = await this.store.getProject(projectId);
@@ -477,7 +500,7 @@ export class CriticalPathEngine {
477
500
  await this.store.logActivity({
478
501
  projectId: created.projectId,
479
502
  taskId: created.id,
480
- actorId: created.reporterId || 'system',
503
+ actorId: this.actorIdOr(created.reporterId || 'system'),
481
504
  action: 'task.created',
482
505
  details: { title: created.title, status: created.status }
483
506
  });
@@ -504,25 +527,30 @@ export class CriticalPathEngine {
504
527
  const existing = await this.store.getTask(id);
505
528
  if (!existing) return null;
506
529
 
530
+ // On a withActor view, the scoped actor replaces any identity claimed in the payload.
531
+ // Explicit options from trusted server-side code still take precedence.
532
+ options = options ?? (this.actor ? { actor: this.actor } : undefined);
533
+ const claimed = this.actor ? undefined : updates;
534
+
507
535
  const actorId =
508
536
  options?.actorId ||
509
- updates.actorId ||
537
+ claimed?.actorId ||
510
538
  options?.actor?.userId ||
511
- updates.actor?.userId ||
539
+ claimed?.actor?.userId ||
512
540
  'system';
513
541
  const actorName =
514
542
  options?.actorName ||
515
- updates.actorName ||
543
+ claimed?.actorName ||
516
544
  options?.actor?.username ||
517
- updates.actor?.username ||
545
+ claimed?.actor?.username ||
518
546
  (actorId === 'system' ? 'System' : undefined);
519
547
  const actorType =
520
548
  options?.actorType ||
521
- updates.actorType ||
549
+ claimed?.actorType ||
522
550
  options?.actor?.actorType ||
523
- updates.actor?.actorType ||
551
+ claimed?.actor?.actorType ||
524
552
  (actorId === 'system' ? 'system' : 'user');
525
- const actorObj = options?.actor || updates.actor || {
553
+ const actorObj = options?.actor || claimed?.actor || {
526
554
  userId: actorId,
527
555
  username: actorName,
528
556
  actorType
@@ -903,7 +931,7 @@ export class CriticalPathEngine {
903
931
  await this.store.logActivity({
904
932
  projectId: downstream.projectId,
905
933
  taskId: downstream.id,
906
- actorId: 'system',
934
+ actorId: this.actorIdOr('system'),
907
935
  action: 'task.unblocked',
908
936
  details: { unblockedByTaskId: completedTask.id }
909
937
  });
@@ -947,7 +975,7 @@ export class CriticalPathEngine {
947
975
  await this.store.logActivity({
948
976
  projectId: existing.projectId,
949
977
  taskId: id,
950
- actorId: 'system',
978
+ actorId: this.actorIdOr('system'),
951
979
  action: 'task.deleted',
952
980
  details: { title: existing.title }
953
981
  });
@@ -1082,7 +1110,7 @@ export class CriticalPathEngine {
1082
1110
 
1083
1111
  const fullEntry: Omit<TimeEntry, 'id' | 'loggedAt'> & { loggedAt?: string } = {
1084
1112
  ...entry,
1085
- userId: entry.userId || task?.assigneeId || 'system',
1113
+ userId: this.actor?.userId || entry.userId || task?.assigneeId || 'system',
1086
1114
  loggedAt: entry.loggedAt
1087
1115
  };
1088
1116
 
@@ -1112,6 +1140,9 @@ export class CriticalPathEngine {
1112
1140
  }
1113
1141
 
1114
1142
  async addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment> {
1143
+ if (this.actor) {
1144
+ comment = { ...comment, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' };
1145
+ }
1115
1146
  const mentions = comment.mentions ?? extractMentions(comment.content);
1116
1147
  const created = await this.store.addComment({ ...comment, mentions });
1117
1148
  const now = new Date().toISOString();
@@ -1178,6 +1209,7 @@ export class CriticalPathEngine {
1178
1209
  }
1179
1210
 
1180
1211
  async addCommentReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
1212
+ if (this.actor) reaction = { ...reaction, userId: this.actor.userId };
1181
1213
  const existing = await this.store.getComment(commentId);
1182
1214
  if (!existing) return null;
1183
1215
 
@@ -1223,6 +1255,7 @@ export class CriticalPathEngine {
1223
1255
  }
1224
1256
 
1225
1257
  async removeCommentReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
1258
+ if (this.actor) reaction = { ...reaction, userId: this.actor.userId };
1226
1259
  const existing = await this.store.getComment(commentId);
1227
1260
  if (!existing) return null;
1228
1261
 
@@ -1267,6 +1300,9 @@ export class CriticalPathEngine {
1267
1300
  }
1268
1301
 
1269
1302
  async createAttachment(attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Attachment> {
1303
+ if (this.actor) {
1304
+ attachment = { ...attachment, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' };
1305
+ }
1270
1306
  validateAttachmentUrl(attachment.url);
1271
1307
  const created = await this.store.createAttachment(attachment);
1272
1308
  const now = new Date().toISOString();
@@ -1498,7 +1534,7 @@ export class CriticalPathEngine {
1498
1534
 
1499
1535
  await this.store.logActivity({
1500
1536
  projectId: created.projectId,
1501
- actorId: 'system',
1537
+ actorId: this.actorIdOr('system'),
1502
1538
  action: 'deliverable.created',
1503
1539
  details: { title: created.title, format: created.format }
1504
1540
  });
@@ -1551,7 +1587,7 @@ export class CriticalPathEngine {
1551
1587
 
1552
1588
  await this.store.logActivity({
1553
1589
  projectId: updated.projectId,
1554
- actorId: 'system',
1590
+ actorId: this.actorIdOr('system'),
1555
1591
  action: 'deliverable.updated',
1556
1592
  details: { title: updated.title, status: updated.status }
1557
1593
  });
@@ -1579,7 +1615,7 @@ export class CriticalPathEngine {
1579
1615
 
1580
1616
  await this.store.logActivity({
1581
1617
  projectId: existing.projectId,
1582
- actorId: 'system',
1618
+ actorId: this.actorIdOr('system'),
1583
1619
  action: 'deliverable.deleted',
1584
1620
  details: { title: existing.title }
1585
1621
  });
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Request payload schemas for Critical Path resources.
3
+ *
4
+ * Exposed from `@critical-path/core/schemas` (not the package root) so browser bundles of core do
5
+ * not pull in zod. Create schemas omit server-assigned fields (`id`, `createdAt`, `updatedAt`, task
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.
9
+ */
10
+ import { z } from 'zod';
11
+ import { ValidationError, type ValidationIssue } from '../domain/errors.js';
12
+
13
+ /**
14
+ * Parses `data` with `schema`, returning the cleaned value (unknown keys stripped, defaults
15
+ * applied) or throwing a `ValidationError` whose `issues` list every problem.
16
+ */
17
+ export function parsePayload<S extends z.ZodTypeAny>(schema: S, data: unknown): z.output<S> {
18
+ const result = schema.safeParse(data ?? {});
19
+ if (result.success) return result.data;
20
+ const issues: ValidationIssue[] = result.error.issues.map((issue) => ({
21
+ path: issue.path.join('.'),
22
+ message: issue.message
23
+ }));
24
+ const summary = issues.map((i) => (i.path ? `${i.path}: ${i.message}` : i.message)).join('; ');
25
+ throw new ValidationError(`Invalid request body: ${summary}`, issues);
26
+ }
27
+
28
+ const isoString = z.string();
29
+ const nonEmpty = z.string().min(1);
30
+
31
+ export const SemanticStatusSchema = z.enum(['not_started', 'in_progress', 'completed', 'canceled']);
32
+ export const AuthorTypeSchema = z.enum(['user', 'agent', 'system']);
33
+
34
+ export const StatusDefinitionSchema = z.object({
35
+ key: nonEmpty,
36
+ label: z.string(),
37
+ category: SemanticStatusSchema
38
+ });
39
+
40
+ export const WorkScheduleSchema = z.object({
41
+ id: z.string().optional(),
42
+ name: z.string().optional(),
43
+ timezone: z.string().optional(),
44
+ defaultHoursPerDay: z.number().nonnegative().optional(),
45
+ days: z.array(
46
+ z.object({
47
+ dayOfWeek: z.number().int().min(0).max(6),
48
+ isWorkingDay: z.boolean(),
49
+ hours: z.array(z.object({ start: z.string(), end: z.string() })).optional()
50
+ })
51
+ ),
52
+ holidays: z
53
+ .array(z.object({ date: z.string(), name: z.string().optional(), halfDay: z.boolean().optional() }))
54
+ .optional()
55
+ });
56
+
57
+ export const CustomFieldDefinitionSchema = z.object({
58
+ id: z.string(),
59
+ key: nonEmpty,
60
+ label: z.string(),
61
+ type: z.enum(['text', 'number', 'date', 'boolean', 'single_select', 'multi_select', 'user']),
62
+ options: z.array(z.string()).optional(),
63
+ required: z.boolean().optional(),
64
+ defaultValue: z.unknown().optional()
65
+ });
66
+
67
+ export const TaskTypeDefinitionSchema = z.object({
68
+ key: nonEmpty,
69
+ label: z.string(),
70
+ description: z.string().optional(),
71
+ icon: z.string().optional(),
72
+ defaultStatusKey: z.string().optional(),
73
+ workflowId: z.string().optional()
74
+ });
75
+
76
+ const customFields = z.record(z.unknown());
77
+ const metadata = z.record(z.unknown());
78
+
79
+ // --- Workflows ---
80
+
81
+ export const CreateWorkflowSchema = z.object({
82
+ name: nonEmpty,
83
+ description: z.string().optional(),
84
+ statuses: z.array(StatusDefinitionSchema),
85
+ transitions: z
86
+ .array(
87
+ z.object({
88
+ id: z.string().optional(),
89
+ name: z.string().optional(),
90
+ fromStatusKey: z.string(),
91
+ toStatusKey: z.string()
92
+ })
93
+ )
94
+ .default([]),
95
+ taskTypes: z.array(TaskTypeDefinitionSchema).optional(),
96
+ defaultStatusKey: z.string().optional(),
97
+ isDefault: z.boolean().optional()
98
+ });
99
+ export const UpdateWorkflowSchema = CreateWorkflowSchema.partial();
100
+
101
+ // --- Projects ---
102
+
103
+ export const CreateProjectSchema = z.object({
104
+ key: z.string().optional(),
105
+ name: nonEmpty,
106
+ description: z.string().optional(),
107
+ ownerId: z.string().optional(),
108
+ members: z.array(z.string()).optional(),
109
+ teamIds: z.array(z.string()).optional(),
110
+ workflowId: z.string().optional(),
111
+ taskTypes: z.array(TaskTypeDefinitionSchema).optional(),
112
+ statusDefinitions: z.array(StatusDefinitionSchema).optional(),
113
+ priorityDefinitions: z
114
+ .array(z.object({ key: z.string(), label: z.string(), level: z.number().optional() }))
115
+ .optional(),
116
+ customFieldDefinitions: z.array(CustomFieldDefinitionSchema).optional(),
117
+ schedule: WorkScheduleSchema.optional(),
118
+ startDate: isoString.optional(),
119
+ targetEndDate: isoString.optional()
120
+ });
121
+ export const UpdateProjectSchema = CreateProjectSchema.partial();
122
+
123
+ // --- Tasks ---
124
+
125
+ const taskFields = {
126
+ title: nonEmpty,
127
+ description: z.string().optional(),
128
+ status: z.string().optional(),
129
+ semanticStatus: SemanticStatusSchema.optional(),
130
+ priority: z.string().optional(),
131
+ taskType: z.string().optional(),
132
+ assigneeId: z.string().optional(),
133
+ assignees: z
134
+ .array(
135
+ z.object({
136
+ id: z.string(),
137
+ name: z.string().optional(),
138
+ role: z.string().optional(),
139
+ type: z.enum(['user', 'agent', 'team']).optional(),
140
+ avatarUrl: z.string().optional()
141
+ })
142
+ )
143
+ .optional(),
144
+ reporterId: z.string().optional(),
145
+ reviewerId: z.string().optional(),
146
+ iterationId: z.string().optional(),
147
+ teamId: z.string().optional(),
148
+ containerId: z.string().optional(),
149
+ deliverableId: z.string().optional(),
150
+ plannedStartDate: isoString.optional(),
151
+ actualStartDate: isoString.optional(),
152
+ actualEndDate: isoString.optional(),
153
+ completedAt: isoString.optional(),
154
+ dueDate: isoString.optional(),
155
+ estimatedHours: z.number().nonnegative().optional(),
156
+ loggedHours: z.number().nonnegative().optional(),
157
+ actualHours: z.number().nonnegative().optional(),
158
+ billableHours: z.number().nonnegative().optional(),
159
+ estimatedDurationMinutes: z.number().nonnegative().optional(),
160
+ actualDurationMinutes: z.number().nonnegative().optional(),
161
+ billableDurationMinutes: z.number().nonnegative().optional(),
162
+ actualDurationSeconds: z.number().nonnegative().optional(),
163
+ inProgressSince: isoString.nullable().optional(),
164
+ blockedDurationSeconds: z.number().nonnegative().optional(),
165
+ blockedSince: isoString.nullable().optional(),
166
+ progress: z.number().min(0).max(100).optional(),
167
+ isBlocked: z.boolean().optional(),
168
+ blockedReason: z.string().nullable().optional(),
169
+ tags: z.array(z.string()).optional(),
170
+ todos: z
171
+ .array(
172
+ z.object({
173
+ id: z.string(),
174
+ title: z.string(),
175
+ completed: z.boolean(),
176
+ createdAt: isoString.optional(),
177
+ completedAt: isoString.optional()
178
+ })
179
+ )
180
+ .optional(),
181
+ customFields: customFields.optional(),
182
+ parentId: z.string().optional()
183
+ };
184
+
185
+ export const CreateTaskSchema = z.object({ projectId: nonEmpty, ...taskFields });
186
+
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);
198
+
199
+ // --- Dependencies ---
200
+
201
+ export const CreateDependencySchema = z.object({
202
+ dependsOnTaskId: nonEmpty,
203
+ type: z.enum(['blocking', 'blocked_by', 'relates_to']).default('blocking')
204
+ });
205
+
206
+ // --- Deliverables ---
207
+
208
+ const deliverableFields = {
209
+ title: nonEmpty,
210
+ description: z.string().optional(),
211
+ status: z.string().optional(),
212
+ format: z.string().optional(),
213
+ specs: z.record(z.unknown()).optional(),
214
+ leadId: z.string().optional(),
215
+ reviewerId: z.string().optional(),
216
+ dueDate: isoString.optional(),
217
+ deliveredAt: isoString.optional(),
218
+ outputUrls: z.array(z.string()).optional(),
219
+ customFields: customFields.optional()
220
+ };
221
+ export const CreateDeliverableSchema = z.object({ projectId: nonEmpty, ...deliverableFields });
222
+ export const UpdateDeliverableSchema = z.object(deliverableFields).partial();
223
+
224
+ // --- Teams ---
225
+
226
+ export const CreateTeamSchema = z.object({
227
+ name: nonEmpty,
228
+ description: z.string().optional(),
229
+ leaderId: z.string().optional(),
230
+ memberIds: z.array(z.string()).default([]),
231
+ weeklyCapacityHours: z.number().nonnegative().optional(),
232
+ schedule: WorkScheduleSchema.optional()
233
+ });
234
+ export const UpdateTeamSchema = CreateTeamSchema.partial();
235
+
236
+ // --- Containers ---
237
+
238
+ const containerFields = {
239
+ name: nonEmpty,
240
+ description: z.string().optional(),
241
+ parentId: z.string().optional(),
242
+ type: z.string().optional(),
243
+ color: z.string().optional()
244
+ };
245
+ export const CreateContainerSchema = z.object({ projectId: nonEmpty, ...containerFields });
246
+ export const UpdateContainerSchema = z.object(containerFields).partial();
247
+
248
+ // --- Iterations ---
249
+
250
+ const iterationFields = {
251
+ name: nonEmpty,
252
+ goal: z.string().optional(),
253
+ type: z.string().optional(),
254
+ startDate: isoString.optional(),
255
+ endDate: isoString.optional(),
256
+ status: z.enum(['planning', 'active', 'completed']).default('planning')
257
+ };
258
+ export const CreateIterationSchema = z.object({ projectId: nonEmpty, ...iterationFields });
259
+ export const UpdateIterationSchema = z
260
+ .object({ ...iterationFields, status: z.enum(['planning', 'active', 'completed']) })
261
+ .partial();
262
+
263
+ // --- Comments & Reactions ---
264
+
265
+ export const CreateCommentSchema = z.object({
266
+ 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
+ parentId: z.string().optional(),
271
+ content: nonEmpty,
272
+ mentions: z.array(z.string()).optional(),
273
+ metadata: metadata.optional()
274
+ });
275
+ export const UpdateCommentSchema = z
276
+ .object({ content: nonEmpty, mentions: z.array(z.string()), metadata })
277
+ .partial();
278
+
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()
283
+ });
284
+
285
+ // --- Attachments ---
286
+
287
+ const attachmentLinks = {
288
+ taskId: z.string().optional(),
289
+ projectId: z.string().optional(),
290
+ 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
+ artifactType: z.enum(['plan', 'spec', 'deliverable', 'review', 'general']).optional(),
295
+ metadata: metadata.optional()
296
+ };
297
+
298
+ export const CreateAttachmentSchema = z.object({
299
+ ...attachmentLinks,
300
+ filename: nonEmpty,
301
+ // Linked (rather than uploaded) files often have no known type or size.
302
+ mimeType: nonEmpty.default('application/octet-stream'),
303
+ sizeBytes: z.number().int().nonnegative().default(0),
304
+ url: nonEmpty,
305
+ storageKey: z.string().optional()
306
+ });
307
+
308
+ export const UploadAttachmentSchema = z.object({
309
+ ...attachmentLinks,
310
+ filename: nonEmpty,
311
+ data: z.string(),
312
+ mimeType: z.string().optional(),
313
+ pathPrefix: z.string().optional(),
314
+ encoding: z.enum(['base64', 'utf-8', 'binary']).optional()
315
+ });
316
+
317
+ export const PresignAttachmentSchema = z.object({
318
+ storageKey: nonEmpty,
319
+ expiresInSeconds: z.number().int().positive().optional(),
320
+ contentType: z.string().optional()
321
+ });
322
+
323
+ // --- Time Tracking ---
324
+
325
+ export const LogTimeSchema = z.object({
326
+ taskId: nonEmpty,
327
+ hours: z.number().positive(),
328
+ isBillable: z.boolean().optional(),
329
+ description: z.string().optional(),
330
+ userId: z.string().optional(),
331
+ loggedAt: isoString.optional()
332
+ });
333
+
334
+ export type CreateTaskPayload = z.infer<typeof CreateTaskSchema>;
335
+ export type UpdateTaskPayload = z.infer<typeof UpdateTaskSchema>;
336
+ export type CreateProjectPayload = z.infer<typeof CreateProjectSchema>;
337
+ export type UpdateProjectPayload = z.infer<typeof UpdateProjectSchema>;
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { z } from 'zod';
3
+ import type {
4
+ CreateTaskInput,
5
+ Task,
6
+ CreateProjectInput,
7
+ Project,
8
+ Workflow,
9
+ CreateDeliverableInput,
10
+ Deliverable,
11
+ Team,
12
+ TaskContainer,
13
+ Iteration,
14
+ Comment,
15
+ CreateAttachmentInput,
16
+ TimeEntry
17
+ } from '../types/index.js';
18
+ import {
19
+ CreateTaskSchema,
20
+ UpdateTaskSchema,
21
+ CreateProjectSchema,
22
+ UpdateProjectSchema,
23
+ CreateWorkflowSchema,
24
+ CreateDeliverableSchema,
25
+ UpdateDeliverableSchema,
26
+ CreateTeamSchema,
27
+ CreateContainerSchema,
28
+ UpdateContainerSchema,
29
+ CreateIterationSchema,
30
+ CreateCommentSchema,
31
+ UpdateCommentSchema,
32
+ CreateAttachmentSchema,
33
+ LogTimeSchema
34
+ } from './index.js';
35
+
36
+ /**
37
+ * Compile-time check that a schema covers exactly the writable keys of a domain type. If a field
38
+ * is added to a type in `types/index.ts` but not to its schema, the build fails here instead of
39
+ * the field being silently stripped from API requests.
40
+ */
41
+ type SameKeys<Schema, Expected> = [Exclude<keyof Schema, keyof Expected>, Exclude<keyof Expected, keyof Schema>] extends [
42
+ never,
43
+ never
44
+ ]
45
+ ? true
46
+ : { extraInSchema: Exclude<keyof Schema, keyof Expected>; missingFromSchema: Exclude<keyof Expected, keyof Schema> };
47
+
48
+ type Infer<S extends z.ZodTypeAny> = z.infer<S>;
49
+ type ServerAssigned = 'id' | 'createdAt' | 'updatedAt';
50
+ type ActorClaims = 'actorId' | 'actorName' | 'actorType' | 'actor';
51
+
52
+ const keyChecks: true[] = [
53
+ true as SameKeys<Infer<typeof CreateTaskSchema>, Omit<CreateTaskInput, 'key'>>,
54
+ true as SameKeys<Omit<Infer<typeof UpdateTaskSchema>, ActorClaims>, Omit<Task, ServerAssigned | 'key' | 'projectId'>>,
55
+ // `workflow` is a denormalised copy resolved from `workflowId`, so it is not writable.
56
+ true as SameKeys<Infer<typeof CreateProjectSchema>, Omit<CreateProjectInput, 'workflow'>>,
57
+ true as SameKeys<Infer<typeof UpdateProjectSchema>, Omit<Project, ServerAssigned | 'workflow'>>,
58
+ true as SameKeys<Infer<typeof CreateWorkflowSchema>, Omit<Workflow, ServerAssigned>>,
59
+ true as SameKeys<Infer<typeof CreateDeliverableSchema>, CreateDeliverableInput>,
60
+ true as SameKeys<Infer<typeof UpdateDeliverableSchema>, Omit<Deliverable, ServerAssigned | 'projectId'>>,
61
+ true as SameKeys<Infer<typeof CreateTeamSchema>, Omit<Team, ServerAssigned>>,
62
+ true as SameKeys<Infer<typeof CreateContainerSchema>, Omit<TaskContainer, ServerAssigned>>,
63
+ true as SameKeys<Infer<typeof UpdateContainerSchema>, Omit<TaskContainer, ServerAssigned | 'projectId'>>,
64
+ true as SameKeys<Infer<typeof CreateIterationSchema>, Omit<Iteration, 'id' | 'createdAt'>>,
65
+ // Reactions are managed through the reactions endpoints.
66
+ true as SameKeys<Infer<typeof CreateCommentSchema>, Omit<Comment, ServerAssigned | 'reactions'>>,
67
+ 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
+ ];
71
+
72
+ describe('request schemas', () => {
73
+ it('cover the writable fields of each domain type', () => {
74
+ expect(keyChecks.every(Boolean)).toBe(true);
75
+ });
76
+
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' });
87
+ });
88
+
89
+ it('reject wrong types and out-of-range values', () => {
90
+ expect(UpdateTaskSchema.safeParse({ status: 123 }).success).toBe(false);
91
+ expect(UpdateTaskSchema.safeParse({ progress: 140 }).success).toBe(false);
92
+ expect(CreateTaskSchema.safeParse({ projectId: 'p1' }).success).toBe(false);
93
+ expect(LogTimeSchema.safeParse({ taskId: 't1', hours: 0 }).success).toBe(false);
94
+ });
95
+
96
+ it('apply defaults for optional collections and enums', () => {
97
+ expect(CreateTeamSchema.parse({ name: 'Core' }).memberIds).toEqual([]);
98
+ expect(CreateIterationSchema.parse({ projectId: 'p1', name: 'S1' }).status).toBe('planning');
99
+ expect(CreateWorkflowSchema.parse({ name: 'W', statuses: [] }).transitions).toEqual([]);
100
+ });
101
+
102
+ it('keep lifecycle fields writable for imports and backfills', () => {
103
+ const parsed = CreateTaskSchema.parse({
104
+ projectId: 'p1',
105
+ title: 'Imported',
106
+ completedAt: '2026-01-02T00:00:00.000Z',
107
+ actualStartDate: '2026-01-01T00:00:00.000Z'
108
+ });
109
+ expect(parsed.completedAt).toBe('2026-01-02T00:00:00.000Z');
110
+ });
111
+ });
@@ -300,6 +300,13 @@ export interface Iteration {
300
300
 
301
301
  export type AuthorType = 'user' | 'agent' | 'system';
302
302
 
303
+ /** The identity a mutation is attributed to (see `CriticalPathEngine.withActor`). */
304
+ export interface Actor {
305
+ userId: string;
306
+ username?: string;
307
+ actorType?: AuthorType;
308
+ }
309
+
303
310
  export interface CommentReaction {
304
311
  emoji: string;
305
312
  userId: string;