@critical-path/core 0.24.0 → 0.26.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +15 -3
  3. package/dist/domain/authorization.d.ts +23 -0
  4. package/dist/domain/authorization.d.ts.map +1 -0
  5. package/dist/domain/authorization.js +62 -0
  6. package/dist/domain/authorization.js.map +1 -0
  7. package/dist/domain/entities.d.ts +2 -2
  8. package/dist/domain/entities.d.ts.map +1 -1
  9. package/dist/domain/entities.js.map +1 -1
  10. package/dist/domain/errors.d.ts +6 -0
  11. package/dist/domain/errors.d.ts.map +1 -1
  12. package/dist/domain/errors.js +9 -0
  13. package/dist/domain/errors.js.map +1 -1
  14. package/dist/domain/index.d.ts +1 -0
  15. package/dist/domain/index.d.ts.map +1 -1
  16. package/dist/domain/index.js +1 -0
  17. package/dist/domain/index.js.map +1 -1
  18. package/dist/engine/actor.test.js +1 -0
  19. package/dist/engine/actor.test.js.map +1 -1
  20. package/dist/engine/authorization.test.d.ts +2 -0
  21. package/dist/engine/authorization.test.d.ts.map +1 -0
  22. package/dist/engine/authorization.test.js +150 -0
  23. package/dist/engine/authorization.test.js.map +1 -0
  24. package/dist/engine/index.d.ts +49 -19
  25. package/dist/engine/index.d.ts.map +1 -1
  26. package/dist/engine/index.js +321 -87
  27. package/dist/engine/index.js.map +1 -1
  28. package/dist/index.test.js +1 -5
  29. package/dist/index.test.js.map +1 -1
  30. package/dist/schemas/index.d.ts +127 -130
  31. package/dist/schemas/index.d.ts.map +1 -1
  32. package/dist/schemas/index.js +41 -50
  33. package/dist/schemas/index.js.map +1 -1
  34. package/dist/schemas/schemas.test.js +10 -11
  35. package/dist/schemas/schemas.test.js.map +1 -1
  36. package/dist/store/sqlite.d.ts.map +1 -1
  37. package/dist/store/sqlite.js +20 -9
  38. package/dist/store/sqlite.js.map +1 -1
  39. package/dist/types/index.d.ts +45 -1
  40. package/dist/types/index.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/domain/authorization.ts +82 -0
  43. package/src/domain/entities.ts +2 -1
  44. package/src/domain/errors.ts +10 -0
  45. package/src/domain/index.ts +1 -0
  46. package/src/engine/actor.test.ts +2 -1
  47. package/src/engine/authorization.test.ts +191 -0
  48. package/src/engine/index.ts +318 -100
  49. package/src/index.test.ts +1 -5
  50. package/src/schemas/index.ts +67 -51
  51. package/src/schemas/schemas.test.ts +19 -17
  52. package/src/store/sqlite.ts +20 -6
  53. package/src/types/index.ts +66 -1
  54. package/tsconfig.tsbuildinfo +1 -1
package/src/index.test.ts CHANGED
@@ -485,11 +485,7 @@ describe('CriticalPathEngine Core Tests', () => {
485
485
  });
486
486
 
487
487
  // Update with explicit actor (e.g. human user updating an assigned agent task)
488
- await engine.updateTask(task.id, {
489
- status: 'in_progress',
490
- actorId: 'user_human_42',
491
- actorName: 'Jack James'
492
- });
488
+ await engine.updateTask(task.id, { status: 'in_progress' }, { actorId: 'user_human_42', actorName: 'Jack James' });
493
489
 
494
490
  const activities = await engine.store.getActivities({ taskId: task.id });
495
491
  const statusActivity = activities.find((a) => a.action === 'task.status_changed');
@@ -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,18 +106,20 @@ 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(),
107
113
  ownerId: z.string().optional(),
108
- members: z.array(z.string()).optional(),
114
+ members: z
115
+ .array(strictObject({ userId: nonEmpty, role: z.enum(['admin', 'project_manager', 'contributor', 'viewer']) }))
116
+ .optional(),
109
117
  teamIds: z.array(z.string()).optional(),
110
118
  workflowId: z.string().optional(),
111
119
  taskTypes: z.array(TaskTypeDefinitionSchema).optional(),
112
120
  statusDefinitions: z.array(StatusDefinitionSchema).optional(),
113
121
  priorityDefinitions: z
114
- .array(z.object({ key: z.string(), label: z.string(), level: z.number().optional() }))
122
+ .array(strictObject({ key: z.string(), label: z.string(), level: z.number().optional() }))
115
123
  .optional(),
116
124
  customFieldDefinitions: z.array(CustomFieldDefinitionSchema).optional(),
117
125
  schedule: WorkScheduleSchema.optional(),
@@ -132,7 +140,7 @@ const taskFields = {
132
140
  assigneeId: z.string().optional(),
133
141
  assignees: z
134
142
  .array(
135
- z.object({
143
+ strictObject({
136
144
  id: z.string(),
137
145
  name: z.string().optional(),
138
146
  role: z.string().optional(),
@@ -169,7 +177,7 @@ const taskFields = {
169
177
  tags: z.array(z.string()).optional(),
170
178
  todos: z
171
179
  .array(
172
- z.object({
180
+ strictObject({
173
181
  id: z.string(),
174
182
  title: z.string(),
175
183
  completed: z.boolean(),
@@ -182,23 +190,13 @@ const taskFields = {
182
190
  parentId: z.string().optional()
183
191
  };
184
192
 
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
- };
193
+ export const CreateTaskSchema = strictObject({ projectId: nonEmpty, ...taskFields });
196
194
 
197
- export const UpdateTaskSchema = z.object(taskFields).partial().extend(actorClaimFields);
195
+ export const UpdateTaskSchema = strictObject(taskFields).partial();
198
196
 
199
197
  // --- Dependencies ---
200
198
 
201
- export const CreateDependencySchema = z.object({
199
+ export const CreateDependencySchema = strictObject({
202
200
  dependsOnTaskId: nonEmpty,
203
201
  type: z.enum(['blocking', 'blocked_by', 'relates_to']).default('blocking')
204
202
  });
@@ -218,12 +216,12 @@ const deliverableFields = {
218
216
  outputUrls: z.array(z.string()).optional(),
219
217
  customFields: customFields.optional()
220
218
  };
221
- export const CreateDeliverableSchema = z.object({ projectId: nonEmpty, ...deliverableFields });
222
- export const UpdateDeliverableSchema = z.object(deliverableFields).partial();
219
+ export const CreateDeliverableSchema = strictObject({ projectId: nonEmpty, ...deliverableFields });
220
+ export const UpdateDeliverableSchema = strictObject(deliverableFields).partial();
223
221
 
224
222
  // --- Teams ---
225
223
 
226
- export const CreateTeamSchema = z.object({
224
+ export const CreateTeamSchema = strictObject({
227
225
  name: nonEmpty,
228
226
  description: z.string().optional(),
229
227
  leaderId: z.string().optional(),
@@ -242,8 +240,8 @@ const containerFields = {
242
240
  type: z.string().optional(),
243
241
  color: z.string().optional()
244
242
  };
245
- export const CreateContainerSchema = z.object({ projectId: nonEmpty, ...containerFields });
246
- export const UpdateContainerSchema = z.object(containerFields).partial();
243
+ export const CreateContainerSchema = strictObject({ projectId: nonEmpty, ...containerFields });
244
+ export const UpdateContainerSchema = strictObject(containerFields).partial();
247
245
 
248
246
  // --- Iterations ---
249
247
 
@@ -255,18 +253,15 @@ const iterationFields = {
255
253
  endDate: isoString.optional(),
256
254
  status: z.enum(['planning', 'active', 'completed']).default('planning')
257
255
  };
258
- export const CreateIterationSchema = z.object({ projectId: nonEmpty, ...iterationFields });
256
+ export const CreateIterationSchema = strictObject({ projectId: nonEmpty, ...iterationFields });
259
257
  export const UpdateIterationSchema = z
260
258
  .object({ ...iterationFields, status: z.enum(['planning', 'active', 'completed']) })
261
259
  .partial();
262
260
 
263
261
  // --- Comments & Reactions ---
264
262
 
265
- export const CreateCommentSchema = z.object({
263
+ export const CreateCommentSchema = strictObject({
266
264
  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
265
  parentId: z.string().optional(),
271
266
  content: nonEmpty,
272
267
  mentions: z.array(z.string()).optional(),
@@ -276,10 +271,8 @@ export const UpdateCommentSchema = z
276
271
  .object({ content: nonEmpty, mentions: z.array(z.string()), metadata })
277
272
  .partial();
278
273
 
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()
274
+ export const CommentReactionSchema = strictObject({
275
+ emoji: nonEmpty
283
276
  });
284
277
 
285
278
  // --- Attachments ---
@@ -288,14 +281,11 @@ const attachmentLinks = {
288
281
  taskId: z.string().optional(),
289
282
  projectId: z.string().optional(),
290
283
  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
284
  artifactType: z.enum(['plan', 'spec', 'deliverable', 'review', 'general']).optional(),
295
285
  metadata: metadata.optional()
296
286
  };
297
287
 
298
- export const CreateAttachmentSchema = z.object({
288
+ export const CreateAttachmentSchema = strictObject({
299
289
  ...attachmentLinks,
300
290
  filename: nonEmpty,
301
291
  // Linked (rather than uploaded) files often have no known type or size.
@@ -305,7 +295,7 @@ export const CreateAttachmentSchema = z.object({
305
295
  storageKey: z.string().optional()
306
296
  });
307
297
 
308
- export const UploadAttachmentSchema = z.object({
298
+ export const UploadAttachmentSchema = strictObject({
309
299
  ...attachmentLinks,
310
300
  filename: nonEmpty,
311
301
  data: z.string(),
@@ -314,7 +304,9 @@ export const UploadAttachmentSchema = z.object({
314
304
  encoding: z.enum(['base64', 'utf-8', 'binary']).optional()
315
305
  });
316
306
 
317
- export const PresignAttachmentSchema = z.object({
307
+ export const PresignAttachmentSchema = strictObject({
308
+ /** Project the upload belongs to; scoped callers may only write under `projects/<projectId>/`. */
309
+ projectId: nonEmpty,
318
310
  storageKey: nonEmpty,
319
311
  expiresInSeconds: z.number().int().positive().optional(),
320
312
  contentType: z.string().optional()
@@ -322,12 +314,11 @@ export const PresignAttachmentSchema = z.object({
322
314
 
323
315
  // --- Time Tracking ---
324
316
 
325
- export const LogTimeSchema = z.object({
317
+ export const LogTimeSchema = strictObject({
326
318
  taskId: nonEmpty,
327
319
  hours: z.number().positive(),
328
320
  isBillable: z.boolean().optional(),
329
321
  description: z.string().optional(),
330
- userId: z.string().optional(),
331
322
  loggedAt: isoString.optional()
332
323
  });
333
324
 
@@ -335,3 +326,28 @@ export type CreateTaskPayload = z.infer<typeof CreateTaskSchema>;
335
326
  export type UpdateTaskPayload = z.infer<typeof UpdateTaskSchema>;
336
327
  export type CreateProjectPayload = z.infer<typeof CreateProjectSchema>;
337
328
  export type UpdateProjectPayload = z.infer<typeof UpdateProjectSchema>;
329
+
330
+ /** Request body types (before defaults are applied), for typed API clients. */
331
+ export type CreateWorkflowBody = z.input<typeof CreateWorkflowSchema>;
332
+ export type UpdateWorkflowBody = z.input<typeof UpdateWorkflowSchema>;
333
+ export type CreateProjectBody = z.input<typeof CreateProjectSchema>;
334
+ export type UpdateProjectBody = z.input<typeof UpdateProjectSchema>;
335
+ export type CreateTaskBody = z.input<typeof CreateTaskSchema>;
336
+ export type UpdateTaskBody = z.input<typeof UpdateTaskSchema>;
337
+ export type CreateDependencyBody = z.input<typeof CreateDependencySchema>;
338
+ export type CreateDeliverableBody = z.input<typeof CreateDeliverableSchema>;
339
+ export type UpdateDeliverableBody = z.input<typeof UpdateDeliverableSchema>;
340
+ export type CreateTeamBody = z.input<typeof CreateTeamSchema>;
341
+ export type UpdateTeamBody = z.input<typeof UpdateTeamSchema>;
342
+ export type CreateContainerBody = z.input<typeof CreateContainerSchema>;
343
+ export type UpdateContainerBody = z.input<typeof UpdateContainerSchema>;
344
+ export type CreateIterationBody = z.input<typeof CreateIterationSchema>;
345
+ export type UpdateIterationBody = z.input<typeof UpdateIterationSchema>;
346
+ export type CreateCommentBody = z.input<typeof CreateCommentSchema>;
347
+ export type UpdateCommentBody = z.input<typeof UpdateCommentSchema>;
348
+ export type CommentReactionBody = z.input<typeof CommentReactionSchema>;
349
+ export type CreateAttachmentBody = z.input<typeof CreateAttachmentSchema>;
350
+ export type UploadAttachmentBody = z.input<typeof UploadAttachmentSchema>;
351
+ export type PresignAttachmentBody = z.input<typeof PresignAttachmentSchema>;
352
+ export type LogTimeBody = z.input<typeof LogTimeSchema>;
353
+
@@ -46,14 +46,17 @@ type SameKeys<Schema, Expected> = [Exclude<keyof Schema, keyof Expected>, Exclud
46
46
  : { extraInSchema: Exclude<keyof Schema, keyof Expected>; missingFromSchema: Exclude<keyof Expected, keyof Schema> };
47
47
 
48
48
  type Infer<S extends z.ZodTypeAny> = z.infer<S>;
49
- type ServerAssigned = 'id' | 'createdAt' | 'updatedAt';
50
- type ActorClaims = 'actorId' | 'actorName' | 'actorType' | 'actor';
49
+ // tenantId is stamped from the actor by the engine, never accepted from payloads.
50
+ type ServerAssigned = 'id' | 'createdAt' | 'updatedAt' | 'tenantId';
51
+ // Identity is resolved by the server (withActor), never accepted in payloads.
52
+ type CommentIdentity = 'authorId' | 'authorType';
53
+ type UploaderIdentity = 'uploaderId' | 'uploaderType';
51
54
 
52
55
  const keyChecks: true[] = [
53
56
  true as SameKeys<Infer<typeof CreateTaskSchema>, Omit<CreateTaskInput, 'key'>>,
54
- true as SameKeys<Omit<Infer<typeof UpdateTaskSchema>, ActorClaims>, Omit<Task, ServerAssigned | 'key' | 'projectId'>>,
57
+ true as SameKeys<Infer<typeof UpdateTaskSchema>, Omit<Task, ServerAssigned | 'key' | 'projectId'>>,
55
58
  // `workflow` is a denormalised copy resolved from `workflowId`, so it is not writable.
56
- true as SameKeys<Infer<typeof CreateProjectSchema>, Omit<CreateProjectInput, 'workflow'>>,
59
+ true as SameKeys<Infer<typeof CreateProjectSchema>, Omit<CreateProjectInput, 'workflow' | 'tenantId'>>,
57
60
  true as SameKeys<Infer<typeof UpdateProjectSchema>, Omit<Project, ServerAssigned | 'workflow'>>,
58
61
  true as SameKeys<Infer<typeof CreateWorkflowSchema>, Omit<Workflow, ServerAssigned>>,
59
62
  true as SameKeys<Infer<typeof CreateDeliverableSchema>, CreateDeliverableInput>,
@@ -63,10 +66,10 @@ const keyChecks: true[] = [
63
66
  true as SameKeys<Infer<typeof UpdateContainerSchema>, Omit<TaskContainer, ServerAssigned | 'projectId'>>,
64
67
  true as SameKeys<Infer<typeof CreateIterationSchema>, Omit<Iteration, 'id' | 'createdAt'>>,
65
68
  // Reactions are managed through the reactions endpoints.
66
- true as SameKeys<Infer<typeof CreateCommentSchema>, Omit<Comment, ServerAssigned | 'reactions'>>,
69
+ true as SameKeys<Infer<typeof CreateCommentSchema>, Omit<Comment, ServerAssigned | 'reactions' | CommentIdentity>>,
67
70
  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'>>
71
+ true as SameKeys<Infer<typeof CreateAttachmentSchema>, Omit<CreateAttachmentInput, UploaderIdentity>>,
72
+ true as SameKeys<Infer<typeof LogTimeSchema>, Omit<TimeEntry, 'id' | 'userId'>>
70
73
  ];
71
74
 
72
75
  describe('request schemas', () => {
@@ -74,16 +77,15 @@ describe('request schemas', () => {
74
77
  expect(keyChecks.every(Boolean)).toBe(true);
75
78
  });
76
79
 
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' });
80
+ it('reject server-assigned, identity and unknown fields', () => {
81
+ for (const extra of [{ id: 'x' }, { projectId: 'other' }, { createdAt: '1999-01-01' }, { key: 'X-1' }, { junk: true }, { actorId: 'ceo' }]) {
82
+ const result = UpdateTaskSchema.safeParse({ title: 'ok', ...extra });
83
+ expect(result.success, JSON.stringify(extra)).toBe(false);
84
+ }
85
+ expect(CreateCommentSchema.safeParse({ taskId: 't1', content: 'hi', authorId: 'ceo' }).success).toBe(false);
86
+ // Nested objects are strict too
87
+ expect(UpdateTaskSchema.safeParse({ todos: [{ id: '1', title: 'a', completed: false, extra: 1 }] }).success).toBe(false);
88
+ expect(UpdateTaskSchema.parse({ title: 'ok' })).toEqual({ title: 'ok' });
87
89
  });
88
90
 
89
91
  it('reject wrong types and out-of-range values', () => {
@@ -279,6 +279,14 @@ export class SQLiteStore implements StorageAdapter {
279
279
  } catch {
280
280
  // Column may already exist
281
281
  }
282
+
283
+ for (const table of ['projects', 'workflows', 'teams']) {
284
+ try {
285
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN tenantId TEXT`);
286
+ } catch {
287
+ // Column may already exist
288
+ }
289
+ }
282
290
  }
283
291
 
284
292
  // --- Projects ---
@@ -301,8 +309,8 @@ export class SQLiteStore implements StorageAdapter {
301
309
  const newProj: Project = { ...project, key, id, createdAt: now, updatedAt: now };
302
310
 
303
311
  const stmt = this.db.prepare(`
304
- INSERT INTO projects (id, key, name, description, ownerId, members, teamIds, workflowId, taskTypes, statusDefinitions, priorityDefinitions, customFieldDefinitions, createdAt, updatedAt)
305
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
312
+ INSERT INTO projects (id, key, name, description, ownerId, members, teamIds, workflowId, taskTypes, statusDefinitions, priorityDefinitions, customFieldDefinitions, tenantId, createdAt, updatedAt)
313
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
306
314
  `);
307
315
  stmt.run(
308
316
  newProj.id,
@@ -317,6 +325,7 @@ export class SQLiteStore implements StorageAdapter {
317
325
  JSON.stringify(newProj.statusDefinitions || []),
318
326
  JSON.stringify(newProj.priorityDefinitions || []),
319
327
  JSON.stringify(newProj.customFieldDefinitions || []),
328
+ newProj.tenantId ?? null,
320
329
  newProj.createdAt,
321
330
  newProj.updatedAt
322
331
  );
@@ -375,8 +384,8 @@ export class SQLiteStore implements StorageAdapter {
375
384
  const newWf: Workflow = { ...workflow, id, createdAt: now, updatedAt: now };
376
385
 
377
386
  const stmt = this.db.prepare(`
378
- INSERT INTO workflows (id, name, description, statuses, transitions, taskTypes, defaultStatusKey, isDefault, createdAt, updatedAt)
379
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
387
+ INSERT INTO workflows (id, name, description, statuses, transitions, taskTypes, defaultStatusKey, isDefault, tenantId, createdAt, updatedAt)
388
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
380
389
  `);
381
390
  stmt.run(
382
391
  newWf.id,
@@ -387,6 +396,7 @@ export class SQLiteStore implements StorageAdapter {
387
396
  newWf.taskTypes ? JSON.stringify(newWf.taskTypes) : null,
388
397
  newWf.defaultStatusKey || null,
389
398
  newWf.isDefault ? 1 : 0,
399
+ newWf.tenantId ?? null,
390
400
  newWf.createdAt,
391
401
  newWf.updatedAt
392
402
  );
@@ -452,8 +462,8 @@ export class SQLiteStore implements StorageAdapter {
452
462
  const newTeam: Team = { ...team, id, createdAt: now, updatedAt: now };
453
463
 
454
464
  const stmt = this.db.prepare(`
455
- INSERT INTO teams (id, name, description, leaderId, memberIds, createdAt, updatedAt)
456
- VALUES (?, ?, ?, ?, ?, ?, ?)
465
+ INSERT INTO teams (id, name, description, leaderId, memberIds, tenantId, createdAt, updatedAt)
466
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
457
467
  `);
458
468
  stmt.run(
459
469
  newTeam.id,
@@ -461,6 +471,7 @@ export class SQLiteStore implements StorageAdapter {
461
471
  newTeam.description || null,
462
472
  newTeam.leaderId || null,
463
473
  JSON.stringify(newTeam.memberIds || []),
474
+ newTeam.tenantId ?? null,
464
475
  newTeam.createdAt,
465
476
  newTeam.updatedAt
466
477
  );
@@ -1195,6 +1206,7 @@ export class SQLiteStore implements StorageAdapter {
1195
1206
  private mapProject(row: any): Project {
1196
1207
  return {
1197
1208
  ...row,
1209
+ tenantId: row.tenantId ?? undefined,
1198
1210
  workflowId: row.workflowId || undefined,
1199
1211
  taskTypes: row.taskTypes ? JSON.parse(row.taskTypes) : undefined,
1200
1212
  members: row.members ? JSON.parse(row.members) : [],
@@ -1208,6 +1220,7 @@ export class SQLiteStore implements StorageAdapter {
1208
1220
  private mapWorkflow(row: any): Workflow {
1209
1221
  return {
1210
1222
  ...row,
1223
+ tenantId: row.tenantId ?? undefined,
1211
1224
  statuses: row.statuses ? JSON.parse(row.statuses) : [],
1212
1225
  transitions: row.transitions ? JSON.parse(row.transitions) : [],
1213
1226
  taskTypes: row.taskTypes ? JSON.parse(row.taskTypes) : undefined,
@@ -1218,6 +1231,7 @@ export class SQLiteStore implements StorageAdapter {
1218
1231
  private mapTeam(row: any): Team {
1219
1232
  return {
1220
1233
  ...row,
1234
+ tenantId: row.tenantId ?? undefined,
1221
1235
  memberIds: row.memberIds ? JSON.parse(row.memberIds) : []
1222
1236
  };
1223
1237
  }
@@ -78,6 +78,8 @@ export interface Team {
78
78
  memberIds: string[];
79
79
  weeklyCapacityHours?: number;
80
80
  schedule?: WorkSchedule;
81
+ /** Tenant that owns the team. Set by the engine from the creating actor. */
82
+ tenantId?: string;
81
83
  createdAt: string;
82
84
  updatedAt: string;
83
85
  }
@@ -117,6 +119,8 @@ export interface Workflow {
117
119
  taskTypes?: TaskTypeDefinition[];
118
120
  defaultStatusKey?: string;
119
121
  isDefault?: boolean;
122
+ /** Tenant that owns the workflow. Set by the engine from the creating actor. */
123
+ tenantId?: string;
120
124
  createdAt: string;
121
125
  updatedAt: string;
122
126
  }
@@ -127,7 +131,10 @@ export interface Project {
127
131
  name: string;
128
132
  description?: string;
129
133
  ownerId?: string;
130
- members?: string[]; // user IDs
134
+ /** Project membership and roles, used by role-based authorization policies. */
135
+ members?: ProjectMember[];
136
+ /** Tenant that owns the project. Set by the engine from the creating actor; never from payloads. */
137
+ tenantId?: string;
131
138
  teamIds?: string[]; // team IDs
132
139
  workflowId?: string;
133
140
  workflow?: Workflow;
@@ -305,6 +312,21 @@ export interface Actor {
305
312
  userId: string;
306
313
  username?: string;
307
314
  actorType?: AuthorType;
315
+ /**
316
+ * Tenant the actor belongs to. When set, the actor only sees projects, workflows and teams
317
+ * with the same `tenantId`, and everything it creates is stamped with it.
318
+ */
319
+ tenantId?: string;
320
+ /** Workspace-wide roles, e.g. `['admin']`. Interpreted by the authorization policy. */
321
+ roles?: string[];
322
+ }
323
+
324
+ /** A user's role on a project. */
325
+ export type ProjectRole = Role;
326
+
327
+ export interface ProjectMember {
328
+ userId: string;
329
+ role: ProjectRole;
308
330
  }
309
331
 
310
332
  export interface CommentReaction {
@@ -462,6 +484,13 @@ export interface CriticalPathPlugin {
462
484
  }
463
485
 
464
486
  export interface CriticalPathConfig {
487
+ /**
488
+ * Authorization policy applied to every call made through a `withActor` view (which is how
489
+ * `@critical-path/server` and the MCP server call the engine). Calls on the base engine are
490
+ * trusted. Use `createRolePolicy()` for project-membership roles, or supply your own.
491
+ * When omitted, actors may do anything within their tenant.
492
+ */
493
+ authorize?: AuthorizationPolicy;
465
494
  store?: 'memory' | 'sqlite' | unknown;
466
495
  fileStorage?: FileStorageAdapter;
467
496
  plugins?: CriticalPathPlugin[];
@@ -742,3 +771,39 @@ export interface WorkloadDistributionOptions {
742
771
  teamSchedules?: Record<string, WorkSchedule>;
743
772
  }
744
773
 
774
+ // ==========================================
775
+ // Authorization
776
+ // ==========================================
777
+
778
+ /**
779
+ * Operations the engine authorizes. Project-scoped actions are checked against a project;
780
+ * `project.create` and `workspace.manage` (workflows, teams, webhooks) are workspace-level.
781
+ */
782
+ export type AuthorizationAction =
783
+ | 'project.read'
784
+ | 'project.create'
785
+ | 'project.update'
786
+ | 'project.delete'
787
+ | 'project.manage_members'
788
+ | 'task.create'
789
+ | 'task.update'
790
+ | 'task.delete'
791
+ | 'comment.create'
792
+ | 'comment.moderate'
793
+ | 'attachment.create'
794
+ | 'attachment.delete'
795
+ | 'time.log'
796
+ | 'plan.manage'
797
+ | 'workspace.manage';
798
+
799
+ export interface AuthorizationRequest {
800
+ actor: Actor;
801
+ action: AuthorizationAction;
802
+ /** The project the action targets, for project-scoped actions. */
803
+ project?: Project;
804
+ /** The specific record, when relevant (e.g. the comment being edited). */
805
+ resource?: { type: 'task' | 'comment' | 'attachment'; ownerId?: string };
806
+ }
807
+
808
+ export type AuthorizationPolicy = (request: AuthorizationRequest) => boolean | Promise<boolean>;
809
+
@@ -1 +1 @@
1
- {"root":["./src/deliverables.test.ts","./src/index.test.ts","./src/index.ts","./src/status.test.ts","./src/task-state.test.ts","./src/use-case-validation.test.ts","./src/domain/calendar.test.ts","./src/domain/calendar.ts","./src/domain/cpm.test.ts","./src/domain/cpm.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/errors.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/domain/ladder.test.ts","./src/domain/ladder.ts","./src/domain/metrics.test.ts","./src/domain/metrics.ts","./src/domain/task-duration.test.ts","./src/domain/workload.test.ts","./src/domain/workload.ts","./src/engine/actor.test.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/schemas/index.ts","./src/schemas/schemas.test.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/fractional-index.test.ts","./src/utils/fractional-index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/mentions.test.ts","./src/utils/mentions.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}
1
+ {"root":["./src/deliverables.test.ts","./src/index.test.ts","./src/index.ts","./src/status.test.ts","./src/task-state.test.ts","./src/use-case-validation.test.ts","./src/domain/authorization.ts","./src/domain/calendar.test.ts","./src/domain/calendar.ts","./src/domain/cpm.test.ts","./src/domain/cpm.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/errors.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/domain/ladder.test.ts","./src/domain/ladder.ts","./src/domain/metrics.test.ts","./src/domain/metrics.ts","./src/domain/task-duration.test.ts","./src/domain/workload.test.ts","./src/domain/workload.ts","./src/engine/actor.test.ts","./src/engine/authorization.test.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/schemas/index.ts","./src/schemas/schemas.test.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/fractional-index.test.ts","./src/utils/fractional-index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/mentions.test.ts","./src/utils/mentions.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}