@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.
Files changed (92) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +25 -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/events.d.ts +5 -0
  15. package/dist/domain/events.d.ts.map +1 -1
  16. package/dist/domain/events.js +34 -0
  17. package/dist/domain/events.js.map +1 -1
  18. package/dist/domain/index.d.ts +1 -0
  19. package/dist/domain/index.d.ts.map +1 -1
  20. package/dist/domain/index.js +1 -0
  21. package/dist/domain/index.js.map +1 -1
  22. package/dist/engine/authorization.test.d.ts +2 -0
  23. package/dist/engine/authorization.test.d.ts.map +1 -0
  24. package/dist/engine/authorization.test.js +150 -0
  25. package/dist/engine/authorization.test.js.map +1 -0
  26. package/dist/engine/index.d.ts +65 -3
  27. package/dist/engine/index.d.ts.map +1 -1
  28. package/dist/engine/index.js +404 -104
  29. package/dist/engine/index.js.map +1 -1
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/schemas/index.d.ts +131 -54
  35. package/dist/schemas/index.d.ts.map +1 -1
  36. package/dist/schemas/index.js +18 -1
  37. package/dist/schemas/index.js.map +1 -1
  38. package/dist/schemas/schemas.test.js +1 -0
  39. package/dist/schemas/schemas.test.js.map +1 -1
  40. package/dist/store/firebase.d.ts +3 -0
  41. package/dist/store/firebase.d.ts.map +1 -1
  42. package/dist/store/firebase.js +21 -0
  43. package/dist/store/firebase.js.map +1 -1
  44. package/dist/store/firebase.test.js +17 -0
  45. package/dist/store/firebase.test.js.map +1 -1
  46. package/dist/store/index.d.ts +6 -0
  47. package/dist/store/index.d.ts.map +1 -1
  48. package/dist/store/index.js +14 -0
  49. package/dist/store/index.js.map +1 -1
  50. package/dist/store/sqlite.d.ts +4 -0
  51. package/dist/store/sqlite.d.ts.map +1 -1
  52. package/dist/store/sqlite.js +62 -17
  53. package/dist/store/sqlite.js.map +1 -1
  54. package/dist/types/index.d.ts +61 -2
  55. package/dist/types/index.d.ts.map +1 -1
  56. package/dist/webhooks/dispatcher.d.ts +80 -0
  57. package/dist/webhooks/dispatcher.d.ts.map +1 -0
  58. package/dist/webhooks/dispatcher.js +133 -0
  59. package/dist/webhooks/dispatcher.js.map +1 -0
  60. package/dist/webhooks/index.d.ts +3 -0
  61. package/dist/webhooks/index.d.ts.map +1 -0
  62. package/dist/webhooks/index.js +3 -0
  63. package/dist/webhooks/index.js.map +1 -0
  64. package/dist/webhooks/signature.d.ts +28 -0
  65. package/dist/webhooks/signature.d.ts.map +1 -0
  66. package/dist/webhooks/signature.js +42 -0
  67. package/dist/webhooks/signature.js.map +1 -0
  68. package/dist/webhooks/webhooks.test.d.ts +2 -0
  69. package/dist/webhooks/webhooks.test.d.ts.map +1 -0
  70. package/dist/webhooks/webhooks.test.js +153 -0
  71. package/dist/webhooks/webhooks.test.js.map +1 -0
  72. package/package.json +1 -1
  73. package/src/domain/authorization.ts +82 -0
  74. package/src/domain/entities.ts +2 -1
  75. package/src/domain/errors.ts +10 -0
  76. package/src/domain/events.ts +42 -3
  77. package/src/domain/index.ts +1 -0
  78. package/src/engine/authorization.test.ts +191 -0
  79. package/src/engine/index.ts +419 -108
  80. package/src/index.ts +1 -0
  81. package/src/schemas/index.ts +22 -1
  82. package/src/schemas/schemas.test.ts +9 -5
  83. package/src/store/firebase.test.ts +20 -0
  84. package/src/store/firebase.ts +22 -0
  85. package/src/store/index.ts +19 -0
  86. package/src/store/sqlite.ts +74 -13
  87. package/src/types/index.ts +81 -29
  88. package/src/webhooks/dispatcher.ts +190 -0
  89. package/src/webhooks/index.ts +2 -0
  90. package/src/webhooks/signature.ts +57 -0
  91. package/src/webhooks/webhooks.test.ts +200 -0
  92. package/tsconfig.tsbuildinfo +1 -1
package/src/index.ts CHANGED
@@ -5,4 +5,5 @@ export * from './storage/index.js';
5
5
  export * from './plugins/index.js';
6
6
  export * from './engine/index.js';
7
7
  export * from './domain/index.js';
8
+ export * from './webhooks/index.js';
8
9
 
@@ -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.array(z.string()).optional(),
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
- type ServerAssigned = 'id' | 'createdAt' | 'updatedAt';
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
 
@@ -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
  }
@@ -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
  }
@@ -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
- ...r,
1169
- events: JSON.parse(r.events),
1170
- active: Boolean(r.active)
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
  }
@@ -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 {
@@ -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
- export type WebhookEvent =
417
- | 'project.created'
418
- | 'project.updated'
419
- | 'project.deleted'
420
- | 'task.created'
421
- | 'task.updated'
422
- | 'task.deleted'
423
- | 'task.status_changed'
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
+