@critical-path/core 0.25.0 → 0.27.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 +52 -0
- package/README.md +25 -0
- 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/events.d.ts +5 -0
- package/dist/domain/events.d.ts.map +1 -1
- package/dist/domain/events.js +34 -0
- package/dist/domain/events.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/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 +65 -3
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +404 -104
- package/dist/engine/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas/index.d.ts +131 -54
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +18 -1
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/schemas.test.js +1 -0
- package/dist/schemas/schemas.test.js.map +1 -1
- package/dist/store/firebase.d.ts +3 -0
- package/dist/store/firebase.d.ts.map +1 -1
- package/dist/store/firebase.js +21 -0
- package/dist/store/firebase.js.map +1 -1
- package/dist/store/firebase.test.js +17 -0
- package/dist/store/firebase.test.js.map +1 -1
- package/dist/store/index.d.ts +6 -0
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +14 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/sqlite.d.ts +4 -0
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +62 -17
- package/dist/store/sqlite.js.map +1 -1
- package/dist/types/index.d.ts +61 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/webhooks/dispatcher.d.ts +80 -0
- package/dist/webhooks/dispatcher.d.ts.map +1 -0
- package/dist/webhooks/dispatcher.js +133 -0
- package/dist/webhooks/dispatcher.js.map +1 -0
- package/dist/webhooks/index.d.ts +3 -0
- package/dist/webhooks/index.d.ts.map +1 -0
- package/dist/webhooks/index.js +3 -0
- package/dist/webhooks/index.js.map +1 -0
- package/dist/webhooks/signature.d.ts +28 -0
- package/dist/webhooks/signature.d.ts.map +1 -0
- package/dist/webhooks/signature.js +42 -0
- package/dist/webhooks/signature.js.map +1 -0
- package/dist/webhooks/webhooks.test.d.ts +2 -0
- package/dist/webhooks/webhooks.test.d.ts.map +1 -0
- package/dist/webhooks/webhooks.test.js +153 -0
- package/dist/webhooks/webhooks.test.js.map +1 -0
- 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/events.ts +42 -3
- package/src/domain/index.ts +1 -0
- package/src/engine/authorization.test.ts +191 -0
- package/src/engine/index.ts +419 -108
- package/src/index.ts +1 -0
- package/src/schemas/index.ts +22 -1
- package/src/schemas/schemas.test.ts +9 -5
- package/src/store/firebase.test.ts +20 -0
- package/src/store/firebase.ts +22 -0
- package/src/store/index.ts +19 -0
- package/src/store/sqlite.ts +74 -13
- package/src/types/index.ts +81 -29
- package/src/webhooks/dispatcher.ts +190 -0
- package/src/webhooks/index.ts +2 -0
- package/src/webhooks/signature.ts +57 -0
- package/src/webhooks/webhooks.test.ts +200 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/index.ts
CHANGED
package/src/schemas/index.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { z } from 'zod';
|
|
12
12
|
import { ValidationError, type ValidationIssue } from '../domain/errors.js';
|
|
13
|
+
import { DOMAIN_EVENT_NAMES } from '../domain/events.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Parses `data` with `schema`, returning the cleaned value (unknown keys stripped, defaults
|
|
@@ -111,7 +112,9 @@ export const CreateProjectSchema = strictObject({
|
|
|
111
112
|
name: nonEmpty,
|
|
112
113
|
description: z.string().optional(),
|
|
113
114
|
ownerId: z.string().optional(),
|
|
114
|
-
members: z
|
|
115
|
+
members: z
|
|
116
|
+
.array(strictObject({ userId: nonEmpty, role: z.enum(['admin', 'project_manager', 'contributor', 'viewer']) }))
|
|
117
|
+
.optional(),
|
|
115
118
|
teamIds: z.array(z.string()).optional(),
|
|
116
119
|
workflowId: z.string().optional(),
|
|
117
120
|
taskTypes: z.array(TaskTypeDefinitionSchema).optional(),
|
|
@@ -303,6 +306,8 @@ export const UploadAttachmentSchema = strictObject({
|
|
|
303
306
|
});
|
|
304
307
|
|
|
305
308
|
export const PresignAttachmentSchema = strictObject({
|
|
309
|
+
/** Project the upload belongs to; scoped callers may only write under `projects/<projectId>/`. */
|
|
310
|
+
projectId: nonEmpty,
|
|
306
311
|
storageKey: nonEmpty,
|
|
307
312
|
expiresInSeconds: z.number().int().positive().optional(),
|
|
308
313
|
contentType: z.string().optional()
|
|
@@ -318,6 +323,20 @@ export const LogTimeSchema = strictObject({
|
|
|
318
323
|
loggedAt: isoString.optional()
|
|
319
324
|
});
|
|
320
325
|
|
|
326
|
+
// --- Webhooks ---
|
|
327
|
+
|
|
328
|
+
export const CreateWebhookSchema = strictObject({
|
|
329
|
+
name: nonEmpty,
|
|
330
|
+
/** http(s) URL; private and local addresses are rejected unless the engine allows them. */
|
|
331
|
+
url: nonEmpty,
|
|
332
|
+
/** Domain event names (e.g. `task.created`) or `'*'` for all events. */
|
|
333
|
+
events: z.array(z.enum(['*', ...DOMAIN_EVENT_NAMES])).min(1),
|
|
334
|
+
/** Signing secret (at least 16 characters). Generated when omitted and returned once. */
|
|
335
|
+
secret: z.string().min(16).optional(),
|
|
336
|
+
active: z.boolean().optional()
|
|
337
|
+
});
|
|
338
|
+
export const UpdateWebhookSchema = CreateWebhookSchema.partial();
|
|
339
|
+
|
|
321
340
|
export type CreateTaskPayload = z.infer<typeof CreateTaskSchema>;
|
|
322
341
|
export type UpdateTaskPayload = z.infer<typeof UpdateTaskSchema>;
|
|
323
342
|
export type CreateProjectPayload = z.infer<typeof CreateProjectSchema>;
|
|
@@ -346,4 +365,6 @@ export type CreateAttachmentBody = z.input<typeof CreateAttachmentSchema>;
|
|
|
346
365
|
export type UploadAttachmentBody = z.input<typeof UploadAttachmentSchema>;
|
|
347
366
|
export type PresignAttachmentBody = z.input<typeof PresignAttachmentSchema>;
|
|
348
367
|
export type LogTimeBody = z.input<typeof LogTimeSchema>;
|
|
368
|
+
export type CreateWebhookBody = z.input<typeof CreateWebhookSchema>;
|
|
369
|
+
export type UpdateWebhookBody = z.input<typeof UpdateWebhookSchema>;
|
|
349
370
|
|
|
@@ -13,7 +13,8 @@ import type {
|
|
|
13
13
|
Iteration,
|
|
14
14
|
Comment,
|
|
15
15
|
CreateAttachmentInput,
|
|
16
|
-
TimeEntry
|
|
16
|
+
TimeEntry,
|
|
17
|
+
Webhook
|
|
17
18
|
} from '../types/index.js';
|
|
18
19
|
import {
|
|
19
20
|
CreateTaskSchema,
|
|
@@ -30,7 +31,8 @@ import {
|
|
|
30
31
|
CreateCommentSchema,
|
|
31
32
|
UpdateCommentSchema,
|
|
32
33
|
CreateAttachmentSchema,
|
|
33
|
-
LogTimeSchema
|
|
34
|
+
LogTimeSchema,
|
|
35
|
+
CreateWebhookSchema
|
|
34
36
|
} from './index.js';
|
|
35
37
|
|
|
36
38
|
/**
|
|
@@ -46,7 +48,8 @@ type SameKeys<Schema, Expected> = [Exclude<keyof Schema, keyof Expected>, Exclud
|
|
|
46
48
|
: { extraInSchema: Exclude<keyof Schema, keyof Expected>; missingFromSchema: Exclude<keyof Expected, keyof Schema> };
|
|
47
49
|
|
|
48
50
|
type Infer<S extends z.ZodTypeAny> = z.infer<S>;
|
|
49
|
-
|
|
51
|
+
// tenantId is stamped from the actor by the engine, never accepted from payloads.
|
|
52
|
+
type ServerAssigned = 'id' | 'createdAt' | 'updatedAt' | 'tenantId';
|
|
50
53
|
// Identity is resolved by the server (withActor), never accepted in payloads.
|
|
51
54
|
type CommentIdentity = 'authorId' | 'authorType';
|
|
52
55
|
type UploaderIdentity = 'uploaderId' | 'uploaderType';
|
|
@@ -55,7 +58,7 @@ const keyChecks: true[] = [
|
|
|
55
58
|
true as SameKeys<Infer<typeof CreateTaskSchema>, Omit<CreateTaskInput, 'key'>>,
|
|
56
59
|
true as SameKeys<Infer<typeof UpdateTaskSchema>, Omit<Task, ServerAssigned | 'key' | 'projectId'>>,
|
|
57
60
|
// `workflow` is a denormalised copy resolved from `workflowId`, so it is not writable.
|
|
58
|
-
true as SameKeys<Infer<typeof CreateProjectSchema>, Omit<CreateProjectInput, 'workflow'>>,
|
|
61
|
+
true as SameKeys<Infer<typeof CreateProjectSchema>, Omit<CreateProjectInput, 'workflow' | 'tenantId'>>,
|
|
59
62
|
true as SameKeys<Infer<typeof UpdateProjectSchema>, Omit<Project, ServerAssigned | 'workflow'>>,
|
|
60
63
|
true as SameKeys<Infer<typeof CreateWorkflowSchema>, Omit<Workflow, ServerAssigned>>,
|
|
61
64
|
true as SameKeys<Infer<typeof CreateDeliverableSchema>, CreateDeliverableInput>,
|
|
@@ -68,7 +71,8 @@ const keyChecks: true[] = [
|
|
|
68
71
|
true as SameKeys<Infer<typeof CreateCommentSchema>, Omit<Comment, ServerAssigned | 'reactions' | CommentIdentity>>,
|
|
69
72
|
true as SameKeys<Infer<typeof UpdateCommentSchema>, Pick<Comment, 'content' | 'mentions' | 'metadata'>>,
|
|
70
73
|
true as SameKeys<Infer<typeof CreateAttachmentSchema>, Omit<CreateAttachmentInput, UploaderIdentity>>,
|
|
71
|
-
true as SameKeys<Infer<typeof LogTimeSchema>, Omit<TimeEntry, 'id' | 'userId'
|
|
74
|
+
true as SameKeys<Infer<typeof LogTimeSchema>, Omit<TimeEntry, 'id' | 'userId'>>,
|
|
75
|
+
true as SameKeys<Infer<typeof CreateWebhookSchema>, Pick<Webhook, 'name' | 'url' | 'events' | 'secret' | 'active'>>
|
|
72
76
|
];
|
|
73
77
|
|
|
74
78
|
describe('request schemas', () => {
|
|
@@ -107,5 +107,25 @@ describe('FirebaseStore', () => {
|
|
|
107
107
|
expect(byUpstream).toHaveLength(1);
|
|
108
108
|
expect(byUpstream[0].id).toBe(dep.id);
|
|
109
109
|
});
|
|
110
|
+
|
|
111
|
+
it('should create, update, clear fields on, and delete webhooks', async () => {
|
|
112
|
+
const created = await store.addWebhook({
|
|
113
|
+
name: 'Hook',
|
|
114
|
+
url: 'https://hooks.example.com/x',
|
|
115
|
+
events: ['task.created'],
|
|
116
|
+
secret: 'whsec_test',
|
|
117
|
+
active: true,
|
|
118
|
+
tenantId: 'acme'
|
|
119
|
+
});
|
|
120
|
+
expect(await store.getWebhook(created.id)).toMatchObject({ name: 'Hook', tenantId: 'acme' });
|
|
121
|
+
|
|
122
|
+
const updated = await store.updateWebhook(created.id, { events: ['*'], secret: undefined });
|
|
123
|
+
expect(updated?.events).toEqual(['*']);
|
|
124
|
+
expect((await store.getWebhook(created.id))?.secret).toBeUndefined();
|
|
125
|
+
|
|
126
|
+
expect(await store.deleteWebhook(created.id)).toBe(true);
|
|
127
|
+
expect(await store.getWebhook(created.id)).toBeNull();
|
|
128
|
+
expect(await store.deleteWebhook(created.id)).toBe(false);
|
|
129
|
+
});
|
|
110
130
|
});
|
|
111
131
|
|
package/src/store/firebase.ts
CHANGED
|
@@ -573,6 +573,11 @@ export class FirebaseStore implements StorageAdapter {
|
|
|
573
573
|
return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
|
|
574
574
|
}
|
|
575
575
|
|
|
576
|
+
async getWebhook(id: string): Promise<Webhook | null> {
|
|
577
|
+
const doc = await this.db.collection('webhooks').doc(id).get();
|
|
578
|
+
return doc.exists ? ({ ...doc.data(), id: doc.id } as Webhook) : null;
|
|
579
|
+
}
|
|
580
|
+
|
|
576
581
|
async addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook> {
|
|
577
582
|
const docRef = this.db.collection('webhooks').doc();
|
|
578
583
|
const now = new Date().toISOString();
|
|
@@ -580,4 +585,21 @@ export class FirebaseStore implements StorageAdapter {
|
|
|
580
585
|
await docRef.set(sanitizeFirestoreData(newWh));
|
|
581
586
|
return newWh;
|
|
582
587
|
}
|
|
588
|
+
|
|
589
|
+
async updateWebhook(id: string, updates: Partial<Omit<Webhook, 'id' | 'createdAt'>>): Promise<Webhook | null> {
|
|
590
|
+
const existing = await this.getWebhook(id);
|
|
591
|
+
if (!existing) return null;
|
|
592
|
+
const updated: Webhook = { ...existing, ...updates, id, createdAt: existing.createdAt };
|
|
593
|
+
// Write the whole document (no merge) so fields cleared with `undefined` are removed.
|
|
594
|
+
await this.db.collection('webhooks').doc(id).set(sanitizeFirestoreData(updated));
|
|
595
|
+
return updated;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async deleteWebhook(id: string): Promise<boolean> {
|
|
599
|
+
const ref = this.db.collection('webhooks').doc(id);
|
|
600
|
+
const doc = await ref.get();
|
|
601
|
+
if (!doc.exists) return false;
|
|
602
|
+
await ref.delete();
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
583
605
|
}
|
package/src/store/index.ts
CHANGED
|
@@ -101,7 +101,10 @@ export interface DependencyRepository {
|
|
|
101
101
|
|
|
102
102
|
export interface WebhookRepository {
|
|
103
103
|
getWebhooks(): Promise<Webhook[]>;
|
|
104
|
+
getWebhook(id: string): Promise<Webhook | null>;
|
|
104
105
|
addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook>;
|
|
106
|
+
updateWebhook(id: string, updates: Partial<Omit<Webhook, 'id' | 'createdAt'>>): Promise<Webhook | null>;
|
|
107
|
+
deleteWebhook(id: string): Promise<boolean>;
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
export interface DeliverableRepository {
|
|
@@ -538,10 +541,26 @@ export class InMemoryStore implements StorageAdapter {
|
|
|
538
541
|
return Array.from(this.webhooks.values());
|
|
539
542
|
}
|
|
540
543
|
|
|
544
|
+
async getWebhook(id: string): Promise<Webhook | null> {
|
|
545
|
+
return this.webhooks.get(id) ?? null;
|
|
546
|
+
}
|
|
547
|
+
|
|
541
548
|
async addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook> {
|
|
542
549
|
const id = `wh_${Math.random().toString(36).substring(2, 9)}`;
|
|
543
550
|
const newWh: Webhook = { ...webhook, id, createdAt: new Date().toISOString() };
|
|
544
551
|
this.webhooks.set(id, newWh);
|
|
545
552
|
return newWh;
|
|
546
553
|
}
|
|
554
|
+
|
|
555
|
+
async updateWebhook(id: string, updates: Partial<Omit<Webhook, 'id' | 'createdAt'>>): Promise<Webhook | null> {
|
|
556
|
+
const existing = this.webhooks.get(id);
|
|
557
|
+
if (!existing) return null;
|
|
558
|
+
const updated: Webhook = { ...existing, ...updates, id, createdAt: existing.createdAt };
|
|
559
|
+
this.webhooks.set(id, updated);
|
|
560
|
+
return updated;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async deleteWebhook(id: string): Promise<boolean> {
|
|
564
|
+
return this.webhooks.delete(id);
|
|
565
|
+
}
|
|
547
566
|
}
|
package/src/store/sqlite.ts
CHANGED
|
@@ -279,6 +279,22 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
279
279
|
} catch {
|
|
280
280
|
// Column may already exist
|
|
281
281
|
}
|
|
282
|
+
|
|
283
|
+
for (const column of ['name TEXT', 'tenantId TEXT']) {
|
|
284
|
+
try {
|
|
285
|
+
this.db.exec(`ALTER TABLE webhooks ADD COLUMN ${column}`);
|
|
286
|
+
} catch {
|
|
287
|
+
// Column may already exist
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (const table of ['projects', 'workflows', 'teams']) {
|
|
292
|
+
try {
|
|
293
|
+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN tenantId TEXT`);
|
|
294
|
+
} catch {
|
|
295
|
+
// Column may already exist
|
|
296
|
+
}
|
|
297
|
+
}
|
|
282
298
|
}
|
|
283
299
|
|
|
284
300
|
// --- Projects ---
|
|
@@ -301,8 +317,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
301
317
|
const newProj: Project = { ...project, key, id, createdAt: now, updatedAt: now };
|
|
302
318
|
|
|
303
319
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
320
|
+
INSERT INTO projects (id, key, name, description, ownerId, members, teamIds, workflowId, taskTypes, statusDefinitions, priorityDefinitions, customFieldDefinitions, tenantId, createdAt, updatedAt)
|
|
321
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
306
322
|
`);
|
|
307
323
|
stmt.run(
|
|
308
324
|
newProj.id,
|
|
@@ -317,6 +333,7 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
317
333
|
JSON.stringify(newProj.statusDefinitions || []),
|
|
318
334
|
JSON.stringify(newProj.priorityDefinitions || []),
|
|
319
335
|
JSON.stringify(newProj.customFieldDefinitions || []),
|
|
336
|
+
newProj.tenantId ?? null,
|
|
320
337
|
newProj.createdAt,
|
|
321
338
|
newProj.updatedAt
|
|
322
339
|
);
|
|
@@ -375,8 +392,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
375
392
|
const newWf: Workflow = { ...workflow, id, createdAt: now, updatedAt: now };
|
|
376
393
|
|
|
377
394
|
const stmt = this.db.prepare(`
|
|
378
|
-
INSERT INTO workflows (id, name, description, statuses, transitions, taskTypes, defaultStatusKey, isDefault, createdAt, updatedAt)
|
|
379
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
395
|
+
INSERT INTO workflows (id, name, description, statuses, transitions, taskTypes, defaultStatusKey, isDefault, tenantId, createdAt, updatedAt)
|
|
396
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
380
397
|
`);
|
|
381
398
|
stmt.run(
|
|
382
399
|
newWf.id,
|
|
@@ -387,6 +404,7 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
387
404
|
newWf.taskTypes ? JSON.stringify(newWf.taskTypes) : null,
|
|
388
405
|
newWf.defaultStatusKey || null,
|
|
389
406
|
newWf.isDefault ? 1 : 0,
|
|
407
|
+
newWf.tenantId ?? null,
|
|
390
408
|
newWf.createdAt,
|
|
391
409
|
newWf.updatedAt
|
|
392
410
|
);
|
|
@@ -452,8 +470,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
452
470
|
const newTeam: Team = { ...team, id, createdAt: now, updatedAt: now };
|
|
453
471
|
|
|
454
472
|
const stmt = this.db.prepare(`
|
|
455
|
-
INSERT INTO teams (id, name, description, leaderId, memberIds, createdAt, updatedAt)
|
|
456
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
473
|
+
INSERT INTO teams (id, name, description, leaderId, memberIds, tenantId, createdAt, updatedAt)
|
|
474
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
457
475
|
`);
|
|
458
476
|
stmt.run(
|
|
459
477
|
newTeam.id,
|
|
@@ -461,6 +479,7 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
461
479
|
newTeam.description || null,
|
|
462
480
|
newTeam.leaderId || null,
|
|
463
481
|
JSON.stringify(newTeam.memberIds || []),
|
|
482
|
+
newTeam.tenantId ?? null,
|
|
464
483
|
newTeam.createdAt,
|
|
465
484
|
newTeam.updatedAt
|
|
466
485
|
);
|
|
@@ -1164,11 +1183,48 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
1164
1183
|
async getWebhooks(): Promise<Webhook[]> {
|
|
1165
1184
|
const stmt = this.db.prepare('SELECT * FROM webhooks');
|
|
1166
1185
|
const rows = stmt.all() as any[];
|
|
1167
|
-
return rows.map((r) => (
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1186
|
+
return rows.map((r) => this.mapWebhook(r));
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
async getWebhook(id: string): Promise<Webhook | null> {
|
|
1190
|
+
const row = this.db.prepare('SELECT * FROM webhooks WHERE id = ?').get(id) as any;
|
|
1191
|
+
return row ? this.mapWebhook(row) : null;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
async updateWebhook(id: string, updates: Partial<Omit<Webhook, 'id' | 'createdAt'>>): Promise<Webhook | null> {
|
|
1195
|
+
const existing = await this.getWebhook(id);
|
|
1196
|
+
if (!existing) return null;
|
|
1197
|
+
const updated: Webhook = { ...existing, ...updates, id, createdAt: existing.createdAt };
|
|
1198
|
+
this.db
|
|
1199
|
+
.prepare('UPDATE webhooks SET name = ?, url = ?, events = ?, secret = ?, active = ?, tenantId = ? WHERE id = ?')
|
|
1200
|
+
.run(
|
|
1201
|
+
updated.name ?? null,
|
|
1202
|
+
updated.url,
|
|
1203
|
+
JSON.stringify(updated.events),
|
|
1204
|
+
updated.secret ?? null,
|
|
1205
|
+
updated.active ? 1 : 0,
|
|
1206
|
+
updated.tenantId ?? null,
|
|
1207
|
+
id
|
|
1208
|
+
);
|
|
1209
|
+
return updated;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
async deleteWebhook(id: string): Promise<boolean> {
|
|
1213
|
+
const result = this.db.prepare('DELETE FROM webhooks WHERE id = ?').run(id);
|
|
1214
|
+
return Number(result.changes) > 0;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
private mapWebhook(row: any): Webhook {
|
|
1218
|
+
return {
|
|
1219
|
+
id: row.id,
|
|
1220
|
+
name: row.name ?? '',
|
|
1221
|
+
url: row.url,
|
|
1222
|
+
events: JSON.parse(row.events),
|
|
1223
|
+
secret: row.secret ?? undefined,
|
|
1224
|
+
active: Boolean(row.active),
|
|
1225
|
+
tenantId: row.tenantId ?? undefined,
|
|
1226
|
+
createdAt: row.createdAt
|
|
1227
|
+
};
|
|
1172
1228
|
}
|
|
1173
1229
|
|
|
1174
1230
|
async addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook> {
|
|
@@ -1177,15 +1233,17 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
1177
1233
|
const newWh: Webhook = { ...webhook, id, createdAt: now };
|
|
1178
1234
|
|
|
1179
1235
|
const stmt = this.db.prepare(`
|
|
1180
|
-
INSERT INTO webhooks (id, url, events, secret, active, createdAt)
|
|
1181
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
1236
|
+
INSERT INTO webhooks (id, name, url, events, secret, active, tenantId, createdAt)
|
|
1237
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1182
1238
|
`);
|
|
1183
1239
|
stmt.run(
|
|
1184
1240
|
newWh.id,
|
|
1241
|
+
newWh.name ?? null,
|
|
1185
1242
|
newWh.url,
|
|
1186
1243
|
JSON.stringify(newWh.events),
|
|
1187
1244
|
newWh.secret || null,
|
|
1188
1245
|
newWh.active ? 1 : 0,
|
|
1246
|
+
newWh.tenantId ?? null,
|
|
1189
1247
|
newWh.createdAt
|
|
1190
1248
|
);
|
|
1191
1249
|
return newWh;
|
|
@@ -1195,6 +1253,7 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
1195
1253
|
private mapProject(row: any): Project {
|
|
1196
1254
|
return {
|
|
1197
1255
|
...row,
|
|
1256
|
+
tenantId: row.tenantId ?? undefined,
|
|
1198
1257
|
workflowId: row.workflowId || undefined,
|
|
1199
1258
|
taskTypes: row.taskTypes ? JSON.parse(row.taskTypes) : undefined,
|
|
1200
1259
|
members: row.members ? JSON.parse(row.members) : [],
|
|
@@ -1208,6 +1267,7 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
1208
1267
|
private mapWorkflow(row: any): Workflow {
|
|
1209
1268
|
return {
|
|
1210
1269
|
...row,
|
|
1270
|
+
tenantId: row.tenantId ?? undefined,
|
|
1211
1271
|
statuses: row.statuses ? JSON.parse(row.statuses) : [],
|
|
1212
1272
|
transitions: row.transitions ? JSON.parse(row.transitions) : [],
|
|
1213
1273
|
taskTypes: row.taskTypes ? JSON.parse(row.taskTypes) : undefined,
|
|
@@ -1218,6 +1278,7 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
1218
1278
|
private mapTeam(row: any): Team {
|
|
1219
1279
|
return {
|
|
1220
1280
|
...row,
|
|
1281
|
+
tenantId: row.tenantId ?? undefined,
|
|
1221
1282
|
memberIds: row.memberIds ? JSON.parse(row.memberIds) : []
|
|
1222
1283
|
};
|
|
1223
1284
|
}
|
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 {
|
|
@@ -407,40 +429,24 @@ export interface Webhook {
|
|
|
407
429
|
id: string;
|
|
408
430
|
name: string;
|
|
409
431
|
url: string;
|
|
432
|
+
/** HMAC-SHA256 signing secret. Never returned by engine reads (see `PublicWebhook`). */
|
|
410
433
|
secret?: string;
|
|
434
|
+
/** Event names to deliver, or `'*'` for every event. */
|
|
411
435
|
events: WebhookEvent[];
|
|
412
436
|
active: boolean;
|
|
437
|
+
/** Tenant whose events this webhook receives. Set from the creating actor. */
|
|
438
|
+
tenantId?: string;
|
|
413
439
|
createdAt: string;
|
|
414
440
|
}
|
|
415
441
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
| 'task.blocked'
|
|
425
|
-
| 'task.unblocked'
|
|
426
|
-
| 'comment.created'
|
|
427
|
-
| 'comment.updated'
|
|
428
|
-
| 'comment.deleted'
|
|
429
|
-
| 'comment.reaction.added'
|
|
430
|
-
| 'comment.reaction.removed'
|
|
431
|
-
| 'attachment.created'
|
|
432
|
-
| 'attachment.deleted'
|
|
433
|
-
| 'iteration.started'
|
|
434
|
-
| 'iteration.completed'
|
|
435
|
-
| 'team.created'
|
|
436
|
-
| 'container.created'
|
|
437
|
-
| 'workflow.created'
|
|
438
|
-
| 'workflow.updated'
|
|
439
|
-
| 'workflow.deleted'
|
|
440
|
-
| 'deliverable.created'
|
|
441
|
-
| 'deliverable.updated'
|
|
442
|
-
| 'deliverable.deleted'
|
|
443
|
-
| 'deliverable.status_changed';
|
|
442
|
+
/** A webhook as returned by engine and API reads: the secret is replaced by `hasSecret`. */
|
|
443
|
+
export type PublicWebhook = Omit<Webhook, 'secret'> & { hasSecret: boolean };
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Webhook subscriptions use domain event names (e.g. `task.created`, `time.logged`); see
|
|
447
|
+
* `CriticalPathDomainEvent` in `domain/events.ts`. `'*'` subscribes to everything.
|
|
448
|
+
*/
|
|
449
|
+
export type WebhookEvent = import('../domain/events.js').CriticalPathDomainEvent['name'] | '*';
|
|
444
450
|
|
|
445
451
|
export interface PluginHooks {
|
|
446
452
|
beforeTaskCreate?: (task: Partial<Task>) => Promise<Partial<Task>> | Partial<Task>;
|
|
@@ -462,10 +468,20 @@ export interface CriticalPathPlugin {
|
|
|
462
468
|
}
|
|
463
469
|
|
|
464
470
|
export interface CriticalPathConfig {
|
|
471
|
+
/**
|
|
472
|
+
* Authorization policy applied to every call made through a `withActor` view (which is how
|
|
473
|
+
* `@critical-path/server` and the MCP server call the engine). Calls on the base engine are
|
|
474
|
+
* trusted. Use `createRolePolicy()` for project-membership roles, or supply your own.
|
|
475
|
+
* When omitted, actors may do anything within their tenant.
|
|
476
|
+
*/
|
|
477
|
+
authorize?: AuthorizationPolicy;
|
|
465
478
|
store?: 'memory' | 'sqlite' | unknown;
|
|
466
479
|
fileStorage?: FileStorageAdapter;
|
|
467
480
|
plugins?: CriticalPathPlugin[];
|
|
481
|
+
/** Static webhooks (not stored or editable through the API). */
|
|
468
482
|
webhooks?: Omit<Webhook, 'id' | 'createdAt'>[];
|
|
483
|
+
/** Delivery behaviour: queue, timeouts, retries, private URL policy, failure callbacks. */
|
|
484
|
+
webhookDelivery?: import('../webhooks/dispatcher.js').WebhookDeliveryOptions;
|
|
469
485
|
defaultSchedule?: WorkSchedule;
|
|
470
486
|
initialData?: {
|
|
471
487
|
projects?: Project[];
|
|
@@ -742,3 +758,39 @@ export interface WorkloadDistributionOptions {
|
|
|
742
758
|
teamSchedules?: Record<string, WorkSchedule>;
|
|
743
759
|
}
|
|
744
760
|
|
|
761
|
+
// ==========================================
|
|
762
|
+
// Authorization
|
|
763
|
+
// ==========================================
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Operations the engine authorizes. Project-scoped actions are checked against a project;
|
|
767
|
+
* `project.create` and `workspace.manage` (workflows, teams, webhooks) are workspace-level.
|
|
768
|
+
*/
|
|
769
|
+
export type AuthorizationAction =
|
|
770
|
+
| 'project.read'
|
|
771
|
+
| 'project.create'
|
|
772
|
+
| 'project.update'
|
|
773
|
+
| 'project.delete'
|
|
774
|
+
| 'project.manage_members'
|
|
775
|
+
| 'task.create'
|
|
776
|
+
| 'task.update'
|
|
777
|
+
| 'task.delete'
|
|
778
|
+
| 'comment.create'
|
|
779
|
+
| 'comment.moderate'
|
|
780
|
+
| 'attachment.create'
|
|
781
|
+
| 'attachment.delete'
|
|
782
|
+
| 'time.log'
|
|
783
|
+
| 'plan.manage'
|
|
784
|
+
| 'workspace.manage';
|
|
785
|
+
|
|
786
|
+
export interface AuthorizationRequest {
|
|
787
|
+
actor: Actor;
|
|
788
|
+
action: AuthorizationAction;
|
|
789
|
+
/** The project the action targets, for project-scoped actions. */
|
|
790
|
+
project?: Project;
|
|
791
|
+
/** The specific record, when relevant (e.g. the comment being edited). */
|
|
792
|
+
resource?: { type: 'task' | 'comment' | 'attachment'; ownerId?: string };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export type AuthorizationPolicy = (request: AuthorizationRequest) => boolean | Promise<boolean>;
|
|
796
|
+
|