@critical-path/core 0.25.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 (47) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +12 -0
  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/authorization.test.d.ts +2 -0
  19. package/dist/engine/authorization.test.d.ts.map +1 -0
  20. package/dist/engine/authorization.test.js +150 -0
  21. package/dist/engine/authorization.test.js.map +1 -0
  22. package/dist/engine/index.d.ts +37 -2
  23. package/dist/engine/index.d.ts.map +1 -1
  24. package/dist/engine/index.js +302 -48
  25. package/dist/engine/index.js.map +1 -1
  26. package/dist/schemas/index.d.ts +40 -6
  27. package/dist/schemas/index.d.ts.map +1 -1
  28. package/dist/schemas/index.js +5 -1
  29. package/dist/schemas/index.js.map +1 -1
  30. package/dist/schemas/schemas.test.js.map +1 -1
  31. package/dist/store/sqlite.d.ts.map +1 -1
  32. package/dist/store/sqlite.js +20 -9
  33. package/dist/store/sqlite.js.map +1 -1
  34. package/dist/types/index.d.ts +45 -1
  35. package/dist/types/index.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/domain/authorization.ts +82 -0
  38. package/src/domain/entities.ts +2 -1
  39. package/src/domain/errors.ts +10 -0
  40. package/src/domain/index.ts +1 -0
  41. package/src/engine/authorization.test.ts +191 -0
  42. package/src/engine/index.ts +295 -49
  43. package/src/schemas/index.ts +5 -1
  44. package/src/schemas/schemas.test.ts +3 -2
  45. package/src/store/sqlite.ts +20 -6
  46. package/src/types/index.ts +66 -1
  47. package/tsconfig.tsbuildinfo +1 -1
@@ -111,7 +111,9 @@ export const CreateProjectSchema = strictObject({
111
111
  name: nonEmpty,
112
112
  description: z.string().optional(),
113
113
  ownerId: z.string().optional(),
114
- members: z.array(z.string()).optional(),
114
+ members: z
115
+ .array(strictObject({ userId: nonEmpty, role: z.enum(['admin', 'project_manager', 'contributor', 'viewer']) }))
116
+ .optional(),
115
117
  teamIds: z.array(z.string()).optional(),
116
118
  workflowId: z.string().optional(),
117
119
  taskTypes: z.array(TaskTypeDefinitionSchema).optional(),
@@ -303,6 +305,8 @@ export const UploadAttachmentSchema = strictObject({
303
305
  });
304
306
 
305
307
  export const PresignAttachmentSchema = strictObject({
308
+ /** Project the upload belongs to; scoped callers may only write under `projects/<projectId>/`. */
309
+ projectId: nonEmpty,
306
310
  storageKey: nonEmpty,
307
311
  expiresInSeconds: z.number().int().positive().optional(),
308
312
  contentType: z.string().optional()
@@ -46,7 +46,8 @@ 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';
49
+ // tenantId is stamped from the actor by the engine, never accepted from payloads.
50
+ type ServerAssigned = 'id' | 'createdAt' | 'updatedAt' | 'tenantId';
50
51
  // Identity is resolved by the server (withActor), never accepted in payloads.
51
52
  type CommentIdentity = 'authorId' | 'authorType';
52
53
  type UploaderIdentity = 'uploaderId' | 'uploaderType';
@@ -55,7 +56,7 @@ const keyChecks: true[] = [
55
56
  true as SameKeys<Infer<typeof CreateTaskSchema>, Omit<CreateTaskInput, 'key'>>,
56
57
  true as SameKeys<Infer<typeof UpdateTaskSchema>, Omit<Task, ServerAssigned | 'key' | 'projectId'>>,
57
58
  // `workflow` is a denormalised copy resolved from `workflowId`, so it is not writable.
58
- true as SameKeys<Infer<typeof CreateProjectSchema>, Omit<CreateProjectInput, 'workflow'>>,
59
+ true as SameKeys<Infer<typeof CreateProjectSchema>, Omit<CreateProjectInput, 'workflow' | 'tenantId'>>,
59
60
  true as SameKeys<Infer<typeof UpdateProjectSchema>, Omit<Project, ServerAssigned | 'workflow'>>,
60
61
  true as SameKeys<Infer<typeof CreateWorkflowSchema>, Omit<Workflow, ServerAssigned>>,
61
62
  true as SameKeys<Infer<typeof CreateDeliverableSchema>, CreateDeliverableInput>,
@@ -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"}