@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.
- package/CHANGELOG.md +53 -0
- package/README.md +15 -3
- package/dist/domain/authorization.d.ts +23 -0
- package/dist/domain/authorization.d.ts.map +1 -0
- package/dist/domain/authorization.js +62 -0
- package/dist/domain/authorization.js.map +1 -0
- package/dist/domain/entities.d.ts +2 -2
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js.map +1 -1
- package/dist/domain/errors.d.ts +6 -0
- package/dist/domain/errors.d.ts.map +1 -1
- package/dist/domain/errors.js +9 -0
- package/dist/domain/errors.js.map +1 -1
- package/dist/domain/index.d.ts +1 -0
- package/dist/domain/index.d.ts.map +1 -1
- package/dist/domain/index.js +1 -0
- package/dist/domain/index.js.map +1 -1
- package/dist/engine/actor.test.js +1 -0
- package/dist/engine/actor.test.js.map +1 -1
- package/dist/engine/authorization.test.d.ts +2 -0
- package/dist/engine/authorization.test.d.ts.map +1 -0
- package/dist/engine/authorization.test.js +150 -0
- package/dist/engine/authorization.test.js.map +1 -0
- package/dist/engine/index.d.ts +49 -19
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +321 -87
- package/dist/engine/index.js.map +1 -1
- package/dist/index.test.js +1 -5
- package/dist/index.test.js.map +1 -1
- package/dist/schemas/index.d.ts +127 -130
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +41 -50
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/schemas.test.js +10 -11
- package/dist/schemas/schemas.test.js.map +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +20 -9
- package/dist/store/sqlite.js.map +1 -1
- package/dist/types/index.d.ts +45 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/domain/authorization.ts +82 -0
- package/src/domain/entities.ts +2 -1
- package/src/domain/errors.ts +10 -0
- package/src/domain/index.ts +1 -0
- package/src/engine/actor.test.ts +2 -1
- package/src/engine/authorization.test.ts +191 -0
- package/src/engine/index.ts +318 -100
- package/src/index.test.ts +1 -5
- package/src/schemas/index.ts +67 -51
- package/src/schemas/schemas.test.ts +19 -17
- package/src/store/sqlite.ts +20 -6
- package/src/types/index.ts +66 -1
- 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');
|
package/src/schemas/index.ts
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
|
-
*
|
|
8
|
-
*
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
52
|
+
strictObject({
|
|
47
53
|
dayOfWeek: z.number().int().min(0).max(6),
|
|
48
54
|
isWorkingDay: z.boolean(),
|
|
49
|
-
hours: z.array(
|
|
55
|
+
hours: z.array(strictObject({ start: z.string(), end: z.string() })).optional()
|
|
50
56
|
})
|
|
51
57
|
),
|
|
52
58
|
holidays: z
|
|
53
|
-
.array(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
195
|
+
export const UpdateTaskSchema = strictObject(taskFields).partial();
|
|
198
196
|
|
|
199
197
|
// --- Dependencies ---
|
|
200
198
|
|
|
201
|
-
export const CreateDependencySchema =
|
|
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 =
|
|
222
|
-
export const UpdateDeliverableSchema =
|
|
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 =
|
|
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 =
|
|
246
|
-
export const UpdateContainerSchema =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
50
|
-
type
|
|
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<
|
|
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('
|
|
78
|
-
const
|
|
79
|
-
title: 'ok',
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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', () => {
|
package/src/store/sqlite.ts
CHANGED
|
@@ -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
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
|
package/tsconfig.tsbuildinfo
CHANGED
|
@@ -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"}
|