@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
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ CriticalPathEngine,
4
+ SQLiteStore,
5
+ InMemoryFileStore,
6
+ createRolePolicy,
7
+ ForbiddenError,
8
+ NotFoundError,
9
+ type ProjectMember
10
+ } from '../index.js';
11
+
12
+ async function setup(members: ProjectMember[]) {
13
+ const engine = new CriticalPathEngine({ authorize: createRolePolicy() });
14
+ const project = await engine.createProject({ key: 'RB', name: 'RBAC', members });
15
+ const task = await engine.createTask({ projectId: project.id, title: 'Task' });
16
+ return { engine, project, task };
17
+ }
18
+
19
+ describe('role-based authorization (createRolePolicy)', () => {
20
+ it('lets viewers read but not write', async () => {
21
+ const { engine, project, task } = await setup([{ userId: 'vic', role: 'viewer' }]);
22
+ const vic = engine.withActor({ userId: 'vic' });
23
+
24
+ expect(await vic.getProject(project.id)).not.toBeNull();
25
+ expect(await vic.getTasks(project.id)).toHaveLength(1);
26
+ await expect(vic.createTask({ projectId: project.id, title: 'Nope' })).rejects.toThrow(ForbiddenError);
27
+ await expect(vic.updateTask(task.id, { title: 'Nope' })).rejects.toThrow(ForbiddenError);
28
+ await expect(vic.addComment({ taskId: task.id, content: 'hi' })).rejects.toThrow(ForbiddenError);
29
+ });
30
+
31
+ it('lets contributors work on tasks but not delete them or manage the plan', async () => {
32
+ const { engine, project, task } = await setup([{ userId: 'cora', role: 'contributor' }]);
33
+ const cora = engine.withActor({ userId: 'cora' });
34
+
35
+ await cora.updateTask(task.id, { title: 'Progress' });
36
+ await cora.logTime({ taskId: task.id, hours: 1 });
37
+ await expect(cora.deleteTask(task.id)).rejects.toThrow(ForbiddenError);
38
+ await expect(cora.createIteration({ projectId: project.id, name: 'S1', status: 'planning' })).rejects.toThrow(ForbiddenError);
39
+ await expect(cora.updateProject(project.id, { name: 'Renamed' })).rejects.toThrow(ForbiddenError);
40
+ });
41
+
42
+ it('lets project managers delete tasks and manage members, and only admins delete projects', async () => {
43
+ const { engine, project, task } = await setup([
44
+ { userId: 'pam', role: 'project_manager' },
45
+ { userId: 'ada', role: 'admin' }
46
+ ]);
47
+ const pam = engine.withActor({ userId: 'pam' });
48
+
49
+ await pam.createIteration({ projectId: project.id, name: 'S1', status: 'planning' });
50
+ await pam.updateProject(project.id, { members: [...project.members!, { userId: 'new', role: 'viewer' }] });
51
+ expect(await pam.deleteTask(task.id)).toBe(true);
52
+ await expect(pam.deleteProject(project.id)).rejects.toThrow(ForbiddenError);
53
+
54
+ expect(await engine.withActor({ userId: 'ada' }).deleteProject(project.id)).toBe(true);
55
+ });
56
+
57
+ it('hides projects from non-members as if they did not exist', async () => {
58
+ const { engine, project, task } = await setup([]);
59
+ const eve = engine.withActor({ userId: 'eve' });
60
+
61
+ expect(await eve.getProjects()).toHaveLength(0);
62
+ expect(await eve.getProject(project.id)).toBeNull();
63
+ expect(await eve.getTask(task.id)).toBeNull();
64
+ expect(await eve.getTasks()).toHaveLength(0);
65
+ expect(await eve.getActivities({ projectId: project.id })).toHaveLength(0);
66
+ expect(await eve.updateTask(task.id, { title: 'x' })).toBeNull();
67
+ await expect(eve.createTask({ projectId: project.id, title: 'x' })).rejects.toThrow(NotFoundError);
68
+ await expect(eve.calculateCriticalPath(project.id)).rejects.toThrow(NotFoundError);
69
+ });
70
+
71
+ it('makes the creator an admin of new projects and denies anonymous creation', async () => {
72
+ const engine = new CriticalPathEngine({ authorize: createRolePolicy() });
73
+ const project = await engine.withActor({ userId: 'carl' }).createProject({ name: 'Mine' });
74
+ expect(project.members).toEqual([{ userId: 'carl', role: 'admin' }]);
75
+
76
+ await expect(engine.withActor({ userId: 'anonymous' }).createProject({ name: 'Nope' })).rejects.toThrow(ForbiddenError);
77
+ });
78
+
79
+ it('lets authors edit their own comments while moderators can edit anyone’s', async () => {
80
+ const { engine, task } = await setup([
81
+ { userId: 'a1', role: 'contributor' },
82
+ { userId: 'a2', role: 'contributor' },
83
+ { userId: 'pm', role: 'project_manager' }
84
+ ]);
85
+ const comment = await engine.withActor({ userId: 'a1' }).addComment({ taskId: task.id, content: 'mine' });
86
+
87
+ await engine.withActor({ userId: 'a1' }).updateComment(comment.id, { content: 'edited' });
88
+ await expect(engine.withActor({ userId: 'a2' }).updateComment(comment.id, { content: 'hijack' })).rejects.toThrow(ForbiddenError);
89
+ expect(await engine.withActor({ userId: 'pm' }).deleteComment(comment.id)).toBe(true);
90
+ });
91
+
92
+ it('treats workspace superusers as allowed everywhere and gates workspace management', async () => {
93
+ const { engine, project } = await setup([{ userId: 'mem', role: 'admin' }]);
94
+ const root = engine.withActor({ userId: 'root', roles: ['admin'] });
95
+
96
+ expect(await root.getProject(project.id)).not.toBeNull();
97
+ await root.createTeam({ name: 'Core', memberIds: [] });
98
+ await expect(engine.withActor({ userId: 'mem' }).createTeam({ name: 'Nope', memberIds: [] })).rejects.toThrow(ForbiddenError);
99
+ });
100
+
101
+ it('runs project deletion cascades without per-task checks', async () => {
102
+ const engine = new CriticalPathEngine({
103
+ authorize: createRolePolicy({ rolePermissions: { admin: ['project.read', 'project.delete'] } })
104
+ });
105
+ const project = await engine.createProject({ name: 'Cascade', members: [{ userId: 'ada', role: 'admin' }] });
106
+ const task = await engine.createTask({ projectId: project.id, title: 'Child' });
107
+
108
+ expect(await engine.withActor({ userId: 'ada' }).deleteProject(project.id)).toBe(true);
109
+ expect(await engine.getTask(task.id)).toBeNull();
110
+ });
111
+
112
+ it('keeps presigned uploads inside the project prefix', async () => {
113
+ const engine = new CriticalPathEngine({ authorize: createRolePolicy(), fileStorage: new InMemoryFileStore() });
114
+ const project = await engine.createProject({ name: 'Files', members: [{ userId: 'up', role: 'contributor' }] });
115
+ const up = engine.withActor({ userId: 'up' });
116
+
117
+ const presigned = await up.getPresignedAttachmentUploadUrl({ projectId: project.id, storageKey: 'spec.pdf' });
118
+ expect(presigned.storageKey).toBe(`projects/${project.id}/spec.pdf`);
119
+ await expect(up.getPresignedAttachmentUploadUrl({ storageKey: 'x' })).rejects.toThrow(/projectId is required/);
120
+ await expect(
121
+ up.getPresignedAttachmentUploadUrl({ projectId: project.id, storageKey: '../other/x' })
122
+ ).rejects.toThrow(/segments/);
123
+ });
124
+
125
+ it('leaves the base engine and views without a policy unrestricted', async () => {
126
+ const { engine, project } = await setup([]);
127
+ expect(await engine.getProject(project.id)).not.toBeNull();
128
+
129
+ const open = new CriticalPathEngine();
130
+ const p = await open.createProject({ name: 'Open' });
131
+ expect(await open.withActor({ userId: 'anyone' }).getProject(p.id)).not.toBeNull();
132
+ });
133
+ });
134
+
135
+ describe('tenant isolation', () => {
136
+ it('scopes projects, workflows and teams to the actor tenant and stamps new records', async () => {
137
+ const engine = new CriticalPathEngine();
138
+ const acme = engine.withActor({ userId: 'a', tenantId: 'acme' });
139
+ const globex = engine.withActor({ userId: 'g', tenantId: 'globex' });
140
+
141
+ const project = await acme.createProject({ name: 'Acme roadmap' });
142
+ expect(project.tenantId).toBe('acme');
143
+ const workflow = await acme.createWorkflow({ name: 'Acme flow', statuses: [], transitions: [] });
144
+ const team = await acme.createTeam({ name: 'Acme team', memberIds: [] });
145
+
146
+ expect(await globex.getProjects()).toHaveLength(0);
147
+ expect(await globex.getProject(project.id)).toBeNull();
148
+ expect(await globex.getWorkflow(workflow.id)).toBeNull();
149
+ expect(await globex.getTeams()).toHaveLength(0);
150
+ expect(await globex.updateTeam(team.id, { name: 'x' })).toBeNull();
151
+
152
+ // Tenancy cannot be changed through a view
153
+ await acme.updateProject(project.id, { tenantId: 'globex' } as any);
154
+ expect((await engine.getProject(project.id))?.tenantId).toBe('acme');
155
+ });
156
+
157
+ it('never lets a view choose a tenant, while trusted base-engine calls may', async () => {
158
+ const engine = new CriticalPathEngine();
159
+ const viaView = await engine.withActor({ userId: 'x' }).createProject({ name: 'View', tenantId: 'acme' } as any);
160
+ expect(viaView.tenantId).toBeUndefined();
161
+
162
+ const seeded = await engine.createProject({ name: 'Seeded', tenantId: 'acme' } as any);
163
+ expect(seeded.tenantId).toBe('acme');
164
+ const team = await engine.createTeam({ name: 'Seeded team', memberIds: [], tenantId: 'acme' } as any);
165
+ expect(team.tenantId).toBe('acme');
166
+ });
167
+
168
+ it('only falls back to default workflows from the project tenant', async () => {
169
+ const engine = new CriticalPathEngine();
170
+ await engine.withActor({ userId: 'g', tenantId: 'globex' }).createWorkflow({
171
+ name: 'Globex default',
172
+ isDefault: true,
173
+ statuses: [{ key: 'only', label: 'Only', category: 'not_started' }],
174
+ transitions: []
175
+ });
176
+ const acmeProject = await engine.withActor({ userId: 'a', tenantId: 'acme' }).createProject({ name: 'Acme' });
177
+
178
+ const workflow = await engine.resolveProjectWorkflow(acmeProject.id);
179
+ expect(workflow?.name).not.toBe('Globex default');
180
+ });
181
+
182
+ it('persists tenancy and membership in SQLiteStore', async () => {
183
+ const engine = new CriticalPathEngine({ store: new SQLiteStore({ filename: ':memory:' }), authorize: createRolePolicy() });
184
+ const project = await engine.withActor({ userId: 'a', tenantId: 'acme' }).createProject({ name: 'Stored' });
185
+
186
+ const stored = await engine.getProject(project.id);
187
+ expect(stored?.tenantId).toBe('acme');
188
+ expect(stored?.members).toEqual([{ userId: 'a', role: 'admin' }]);
189
+ expect(await engine.withActor({ userId: 'a', tenantId: 'other' }).getProject(project.id)).toBeNull();
190
+ });
191
+ });