@critical-path/core 0.26.0 → 0.28.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 (83) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +15 -0
  3. package/dist/domain/custom-fields.d.ts +7 -2
  4. package/dist/domain/custom-fields.d.ts.map +1 -1
  5. package/dist/domain/custom-fields.js +23 -1
  6. package/dist/domain/custom-fields.js.map +1 -1
  7. package/dist/domain/events.d.ts +5 -0
  8. package/dist/domain/events.d.ts.map +1 -1
  9. package/dist/domain/events.js +34 -0
  10. package/dist/domain/events.js.map +1 -1
  11. package/dist/engine/index.d.ts +30 -2
  12. package/dist/engine/index.d.ts.map +1 -1
  13. package/dist/engine/index.js +133 -77
  14. package/dist/engine/index.js.map +1 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/plugins/index.d.ts +13 -3
  20. package/dist/plugins/index.d.ts.map +1 -1
  21. package/dist/plugins/index.js +44 -15
  22. package/dist/plugins/index.js.map +1 -1
  23. package/dist/plugins/plugins.test.d.ts +2 -0
  24. package/dist/plugins/plugins.test.d.ts.map +1 -0
  25. package/dist/plugins/plugins.test.js +137 -0
  26. package/dist/plugins/plugins.test.js.map +1 -0
  27. package/dist/schemas/index.d.ts +167 -121
  28. package/dist/schemas/index.d.ts.map +1 -1
  29. package/dist/schemas/index.js +15 -1
  30. package/dist/schemas/index.js.map +1 -1
  31. package/dist/schemas/schemas.test.js +1 -0
  32. package/dist/schemas/schemas.test.js.map +1 -1
  33. package/dist/store/firebase.d.ts +3 -0
  34. package/dist/store/firebase.d.ts.map +1 -1
  35. package/dist/store/firebase.js +21 -0
  36. package/dist/store/firebase.js.map +1 -1
  37. package/dist/store/firebase.test.js +17 -0
  38. package/dist/store/firebase.test.js.map +1 -1
  39. package/dist/store/index.d.ts +6 -0
  40. package/dist/store/index.d.ts.map +1 -1
  41. package/dist/store/index.js +14 -0
  42. package/dist/store/index.js.map +1 -1
  43. package/dist/store/sqlite.d.ts +4 -0
  44. package/dist/store/sqlite.d.ts.map +1 -1
  45. package/dist/store/sqlite.js +42 -8
  46. package/dist/store/sqlite.js.map +1 -1
  47. package/dist/types/index.d.ts +67 -6
  48. package/dist/types/index.d.ts.map +1 -1
  49. package/dist/webhooks/dispatcher.d.ts +80 -0
  50. package/dist/webhooks/dispatcher.d.ts.map +1 -0
  51. package/dist/webhooks/dispatcher.js +133 -0
  52. package/dist/webhooks/dispatcher.js.map +1 -0
  53. package/dist/webhooks/index.d.ts +3 -0
  54. package/dist/webhooks/index.d.ts.map +1 -0
  55. package/dist/webhooks/index.js +3 -0
  56. package/dist/webhooks/index.js.map +1 -0
  57. package/dist/webhooks/signature.d.ts +28 -0
  58. package/dist/webhooks/signature.d.ts.map +1 -0
  59. package/dist/webhooks/signature.js +42 -0
  60. package/dist/webhooks/signature.js.map +1 -0
  61. package/dist/webhooks/webhooks.test.d.ts +2 -0
  62. package/dist/webhooks/webhooks.test.d.ts.map +1 -0
  63. package/dist/webhooks/webhooks.test.js +153 -0
  64. package/dist/webhooks/webhooks.test.js.map +1 -0
  65. package/package.json +1 -1
  66. package/src/domain/custom-fields.ts +43 -2
  67. package/src/domain/events.ts +42 -3
  68. package/src/engine/index.ts +170 -84
  69. package/src/index.ts +1 -0
  70. package/src/plugins/index.ts +51 -16
  71. package/src/plugins/plugins.test.ts +161 -0
  72. package/src/schemas/index.ts +19 -1
  73. package/src/schemas/schemas.test.ts +6 -3
  74. package/src/store/firebase.test.ts +20 -0
  75. package/src/store/firebase.ts +22 -0
  76. package/src/store/index.ts +19 -0
  77. package/src/store/sqlite.ts +54 -7
  78. package/src/types/index.ts +72 -33
  79. package/src/webhooks/dispatcher.ts +190 -0
  80. package/src/webhooks/index.ts +2 -0
  81. package/src/webhooks/signature.ts +57 -0
  82. package/src/webhooks/webhooks.test.ts +200 -0
  83. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,161 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ CriticalPathEngine,
4
+ CustomFieldValidationError,
5
+ ValidationError,
6
+ WorkflowValidationError,
7
+ type CriticalPathPlugin
8
+ } from '../index.js';
9
+
10
+ const strictWorkflow = {
11
+ name: 'Strict',
12
+ defaultStatusKey: 'todo',
13
+ statuses: [
14
+ { key: 'todo', label: 'To Do', category: 'not_started' as const },
15
+ { key: 'doing', label: 'Doing', category: 'in_progress' as const },
16
+ { key: 'done', label: 'Done', category: 'completed' as const }
17
+ ],
18
+ transitions: [
19
+ { fromStatusKey: 'todo', toStatusKey: 'doing' },
20
+ { fromStatusKey: 'doing', toStatusKey: 'done' }
21
+ ]
22
+ };
23
+
24
+ describe('plugin system', () => {
25
+ it('runs init once with the engine before ready resolves', async () => {
26
+ const init = vi.fn(async (engine: CriticalPathEngine) => {
27
+ await engine.createProject({ name: 'Seeded by plugin' });
28
+ });
29
+ const engine = new CriticalPathEngine({ plugins: [{ id: 'seed', name: 'Seed', version: '1', init }] });
30
+ await engine.ready;
31
+
32
+ expect(init).toHaveBeenCalledTimes(1);
33
+ expect(init).toHaveBeenCalledWith(engine);
34
+ expect(await engine.getProjects()).toHaveLength(1);
35
+ });
36
+
37
+ it('surfaces init failures through ready without unhandled rejections', async () => {
38
+ const engine = new CriticalPathEngine({
39
+ plugins: [{ id: 'bad', name: 'Bad', version: '1', init: () => Promise.reject(new Error('boom')) }]
40
+ });
41
+ await expect(engine.ready).rejects.toThrow('boom');
42
+ });
43
+
44
+ it('validates values of plugin-registered custom field types and rejects unknown types', async () => {
45
+ const urlField: CriticalPathPlugin = {
46
+ id: 'url-field',
47
+ name: 'URL field',
48
+ version: '1',
49
+ customFieldTypes: [
50
+ { type: 'url', validate: (value) => (typeof value === 'string' && /^https?:\/\//.test(value) ? null : 'must be an http(s) URL') }
51
+ ]
52
+ };
53
+ const engine = new CriticalPathEngine({ plugins: [urlField] });
54
+ const project = await engine.createProject({
55
+ name: 'Links',
56
+ customFieldDefinitions: [{ id: 'f1', key: 'spec', label: 'Spec', type: 'url' }]
57
+ });
58
+
59
+ await engine.createTask({ projectId: project.id, title: 'ok', customFields: { spec: 'https://example.com' } });
60
+ await expect(engine.createTask({ projectId: project.id, title: 'bad', customFields: { spec: 'ftp://x' } })).rejects.toThrow(
61
+ /must be an http\(s\) URL/
62
+ );
63
+ await expect(
64
+ engine.createProject({ name: 'Unknown', customFieldDefinitions: [{ id: 'f', key: 'x', label: 'X', type: 'nope' }] })
65
+ ).rejects.toThrow(CustomFieldValidationError);
66
+ });
67
+
68
+ it('refuses custom field types that clash with built-in or already registered types', () => {
69
+ const clash = (type: string): CriticalPathPlugin => ({
70
+ id: `p-${type}-${Math.random()}`,
71
+ name: 'Clash',
72
+ version: '1',
73
+ customFieldTypes: [{ type, validate: () => null }]
74
+ });
75
+ expect(() => new CriticalPathEngine({ plugins: [clash('number')] })).toThrow(/already exists/);
76
+ expect(() => new CriticalPathEngine({ plugins: [clash('money'), clash('money')] })).toThrow(/already exists/);
77
+ });
78
+
79
+ it('validates the output of before-hooks, so plugins cannot bypass workflow rules', async () => {
80
+ const skipAhead: CriticalPathPlugin = {
81
+ id: 'skip',
82
+ name: 'Skip ahead',
83
+ version: '1',
84
+ hooks: { beforeTaskUpdate: (_id, updates) => ({ ...updates, status: 'done' }) }
85
+ };
86
+ const engine = new CriticalPathEngine({ plugins: [skipAhead] });
87
+ const workflow = await engine.createWorkflow(strictWorkflow);
88
+ const project = await engine.createProject({ name: 'Strict', workflowId: workflow.id });
89
+ const task = await engine.createTask({ projectId: project.id, title: 'T' });
90
+
91
+ await expect(engine.updateTask(task.id, { title: 'renamed' })).rejects.toThrow(WorkflowValidationError);
92
+ expect((await engine.getTask(task.id))?.status).toBe('todo');
93
+ });
94
+
95
+ it('prevents before-hooks from moving tasks between projects', async () => {
96
+ let otherProjectId = '';
97
+ const mover: CriticalPathPlugin = {
98
+ id: 'mover',
99
+ name: 'Mover',
100
+ version: '1',
101
+ hooks: {
102
+ beforeTaskCreate: (task) => (task.title === 'move me' ? { ...task, projectId: otherProjectId } : task),
103
+ beforeTaskUpdate: (_id, updates) => ({ ...updates, projectId: otherProjectId })
104
+ }
105
+ };
106
+ const engine = new CriticalPathEngine({ plugins: [mover] });
107
+ const home = await engine.createProject({ name: 'Home' });
108
+ otherProjectId = (await engine.createProject({ name: 'Other' })).id;
109
+
110
+ await expect(engine.createTask({ projectId: home.id, title: 'move me' })).rejects.toThrow(ValidationError);
111
+ const task = await engine.createTask({ projectId: home.id, title: 'stay' });
112
+ await engine.updateTask(task.id, { title: 'still here' });
113
+ expect((await engine.getTask(task.id))?.projectId).toBe(home.id);
114
+ });
115
+
116
+ it('enforces required custom fields even when none are supplied', async () => {
117
+ const engine = new CriticalPathEngine();
118
+ const project = await engine.createProject({
119
+ name: 'Required',
120
+ customFieldDefinitions: [{ id: 'f', key: 'client', label: 'Client', type: 'text', required: true }]
121
+ });
122
+ await expect(engine.createTask({ projectId: project.id, title: 'missing' })).rejects.toThrow(/required/);
123
+ });
124
+
125
+ it('logs after-hook failures without failing the write or skipping events', async () => {
126
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
127
+ const engine = new CriticalPathEngine({
128
+ plugins: [{ id: 'flaky', name: 'Flaky', version: '1', hooks: { afterTaskCreate: () => { throw new Error('downstream'); } } }]
129
+ });
130
+ const published: string[] = [];
131
+ engine.events.subscribe('task.created', (e) => void published.push(e.name));
132
+
133
+ const project = await engine.createProject({ name: 'After' });
134
+ const task = await engine.createTask({ projectId: project.id, title: 'stored' });
135
+ expect(await engine.getTask(task.id)).not.toBeNull();
136
+ expect(published).toEqual(['task.created']);
137
+ expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('"flaky" afterTaskCreate'), expect.any(Error));
138
+ consoleError.mockRestore();
139
+ });
140
+
141
+ it('passes the deleted task to delete hooks', async () => {
142
+ const seen: string[] = [];
143
+ const engine = new CriticalPathEngine({
144
+ plugins: [
145
+ {
146
+ id: 'audit',
147
+ name: 'Audit',
148
+ version: '1',
149
+ hooks: {
150
+ beforeTaskDelete: (_id, task) => void seen.push(`before:${task.title}`),
151
+ afterTaskDelete: (_id, task) => void seen.push(`after:${task.title}`)
152
+ }
153
+ }
154
+ ]
155
+ });
156
+ const project = await engine.createProject({ name: 'Del' });
157
+ const task = await engine.createTask({ projectId: project.id, title: 'gone' });
158
+ await engine.deleteTask(task.id);
159
+ expect(seen).toEqual(['before:gone', 'after:gone']);
160
+ });
161
+ });
@@ -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
@@ -64,7 +65,8 @@ export const CustomFieldDefinitionSchema = strictObject({
64
65
  id: z.string(),
65
66
  key: nonEmpty,
66
67
  label: z.string(),
67
- type: z.enum(['text', 'number', 'date', 'boolean', 'single_select', 'multi_select', 'user']),
68
+ /** Built-in type or one registered by a plugin (checked by the engine). */
69
+ type: nonEmpty,
68
70
  options: z.array(z.string()).optional(),
69
71
  required: z.boolean().optional(),
70
72
  defaultValue: z.unknown().optional()
@@ -322,6 +324,20 @@ export const LogTimeSchema = strictObject({
322
324
  loggedAt: isoString.optional()
323
325
  });
324
326
 
327
+ // --- Webhooks ---
328
+
329
+ export const CreateWebhookSchema = strictObject({
330
+ name: nonEmpty,
331
+ /** http(s) URL; private and local addresses are rejected unless the engine allows them. */
332
+ url: nonEmpty,
333
+ /** Domain event names (e.g. `task.created`) or `'*'` for all events. */
334
+ events: z.array(z.enum(['*', ...DOMAIN_EVENT_NAMES])).min(1),
335
+ /** Signing secret (at least 16 characters). Generated when omitted and returned once. */
336
+ secret: z.string().min(16).optional(),
337
+ active: z.boolean().optional()
338
+ });
339
+ export const UpdateWebhookSchema = CreateWebhookSchema.partial();
340
+
325
341
  export type CreateTaskPayload = z.infer<typeof CreateTaskSchema>;
326
342
  export type UpdateTaskPayload = z.infer<typeof UpdateTaskSchema>;
327
343
  export type CreateProjectPayload = z.infer<typeof CreateProjectSchema>;
@@ -350,4 +366,6 @@ export type CreateAttachmentBody = z.input<typeof CreateAttachmentSchema>;
350
366
  export type UploadAttachmentBody = z.input<typeof UploadAttachmentSchema>;
351
367
  export type PresignAttachmentBody = z.input<typeof PresignAttachmentSchema>;
352
368
  export type LogTimeBody = z.input<typeof LogTimeSchema>;
369
+ export type CreateWebhookBody = z.input<typeof CreateWebhookSchema>;
370
+ export type UpdateWebhookBody = z.input<typeof UpdateWebhookSchema>;
353
371
 
@@ -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
  /**
@@ -69,7 +71,8 @@ const keyChecks: true[] = [
69
71
  true as SameKeys<Infer<typeof CreateCommentSchema>, Omit<Comment, ServerAssigned | 'reactions' | CommentIdentity>>,
70
72
  true as SameKeys<Infer<typeof UpdateCommentSchema>, Pick<Comment, 'content' | 'mentions' | 'metadata'>>,
71
73
  true as SameKeys<Infer<typeof CreateAttachmentSchema>, Omit<CreateAttachmentInput, UploaderIdentity>>,
72
- 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'>>
73
76
  ];
74
77
 
75
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
  }
@@ -280,6 +280,14 @@ export class SQLiteStore implements StorageAdapter {
280
280
  // Column may already exist
281
281
  }
282
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
+
283
291
  for (const table of ['projects', 'workflows', 'teams']) {
284
292
  try {
285
293
  this.db.exec(`ALTER TABLE ${table} ADD COLUMN tenantId TEXT`);
@@ -1175,11 +1183,48 @@ export class SQLiteStore implements StorageAdapter {
1175
1183
  async getWebhooks(): Promise<Webhook[]> {
1176
1184
  const stmt = this.db.prepare('SELECT * FROM webhooks');
1177
1185
  const rows = stmt.all() as any[];
1178
- return rows.map((r) => ({
1179
- ...r,
1180
- events: JSON.parse(r.events),
1181
- active: Boolean(r.active)
1182
- }));
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
+ };
1183
1228
  }
1184
1229
 
1185
1230
  async addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook> {
@@ -1188,15 +1233,17 @@ export class SQLiteStore implements StorageAdapter {
1188
1233
  const newWh: Webhook = { ...webhook, id, createdAt: now };
1189
1234
 
1190
1235
  const stmt = this.db.prepare(`
1191
- INSERT INTO webhooks (id, url, events, secret, active, createdAt)
1192
- VALUES (?, ?, ?, ?, ?, ?)
1236
+ INSERT INTO webhooks (id, name, url, events, secret, active, tenantId, createdAt)
1237
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1193
1238
  `);
1194
1239
  stmt.run(
1195
1240
  newWh.id,
1241
+ newWh.name ?? null,
1196
1242
  newWh.url,
1197
1243
  JSON.stringify(newWh.events),
1198
1244
  newWh.secret || null,
1199
1245
  newWh.active ? 1 : 0,
1246
+ newWh.tenantId ?? null,
1200
1247
  newWh.createdAt
1201
1248
  );
1202
1249
  return newWh;
@@ -88,7 +88,8 @@ export interface CustomFieldDefinition {
88
88
  id: string;
89
89
  key: string;
90
90
  label: string;
91
- type: 'text' | 'number' | 'date' | 'boolean' | 'single_select' | 'multi_select' | 'user';
91
+ /** A built-in type, or a type registered by a plugin's `customFieldTypes`. */
92
+ type: 'text' | 'number' | 'date' | 'boolean' | 'single_select' | 'multi_select' | 'user' | (string & {});
92
93
  options?: string[];
93
94
  required?: boolean;
94
95
  defaultValue?: unknown;
@@ -429,58 +430,93 @@ export interface Webhook {
429
430
  id: string;
430
431
  name: string;
431
432
  url: string;
433
+ /** HMAC-SHA256 signing secret. Never returned by engine reads (see `PublicWebhook`). */
432
434
  secret?: string;
435
+ /** Event names to deliver, or `'*'` for every event. */
433
436
  events: WebhookEvent[];
434
437
  active: boolean;
438
+ /** Tenant whose events this webhook receives. Set from the creating actor. */
439
+ tenantId?: string;
435
440
  createdAt: string;
436
441
  }
437
442
 
438
- export type WebhookEvent =
439
- | 'project.created'
440
- | 'project.updated'
441
- | 'project.deleted'
442
- | 'task.created'
443
- | 'task.updated'
444
- | 'task.deleted'
445
- | 'task.status_changed'
446
- | 'task.blocked'
447
- | 'task.unblocked'
448
- | 'comment.created'
449
- | 'comment.updated'
450
- | 'comment.deleted'
451
- | 'comment.reaction.added'
452
- | 'comment.reaction.removed'
453
- | 'attachment.created'
454
- | 'attachment.deleted'
455
- | 'iteration.started'
456
- | 'iteration.completed'
457
- | 'team.created'
458
- | 'container.created'
459
- | 'workflow.created'
460
- | 'workflow.updated'
461
- | 'workflow.deleted'
462
- | 'deliverable.created'
463
- | 'deliverable.updated'
464
- | 'deliverable.deleted'
465
- | 'deliverable.status_changed';
443
+ /** A webhook as returned by engine and API reads: the secret is replaced by `hasSecret`. */
444
+ export type PublicWebhook = Omit<Webhook, 'secret'> & { hasSecret: boolean };
445
+
446
+ /**
447
+ * Webhook subscriptions use domain event names (e.g. `task.created`, `time.logged`); see
448
+ * `CriticalPathDomainEvent` in `domain/events.ts`. `'*'` subscribes to everything.
449
+ */
450
+ export type WebhookEvent = import('../domain/events.js').CriticalPathDomainEvent['name'] | '*';
466
451
 
452
+ /**
453
+ * Lifecycle hooks, run in plugin registration order.
454
+ *
455
+ * - `before*` hooks may transform the input or throw to abort the operation. Their output is
456
+ * validated (workflow transitions, custom fields) exactly like caller input, and they cannot
457
+ * move a task to another project.
458
+ * - `after*` hooks run once the change is stored. Errors are logged and do not fail the call.
459
+ */
467
460
  export interface PluginHooks {
468
461
  beforeTaskCreate?: (task: Partial<Task>) => Promise<Partial<Task>> | Partial<Task>;
469
462
  afterTaskCreate?: (task: Task) => Promise<void> | void;
470
463
  beforeTaskUpdate?: (id: string, updates: Partial<Task>) => Promise<Partial<Task>> | Partial<Task>;
471
464
  afterTaskUpdate?: (task: Task, previousState: Task) => Promise<void> | void;
472
- beforeTaskDelete?: (id: string) => Promise<void> | void;
473
- afterTaskDelete?: (id: string) => Promise<void> | void;
465
+ beforeTaskDelete?: (id: string, task: Task) => Promise<void> | void;
466
+ afterTaskDelete?: (id: string, task: Task) => Promise<void> | void;
467
+ }
468
+
469
+ /** A custom field type contributed by a plugin, e.g. `url` or `currency`. */
470
+ export interface CustomFieldType {
471
+ /** The `type` value used in `CustomFieldDefinition`s. Must not clash with a built-in type. */
472
+ type: string;
473
+ label?: string;
474
+ /** Returns an error message for an invalid value, or nothing when valid. Not called for empty values. */
475
+ validate: (value: unknown, definition: CustomFieldDefinition) => string | null | undefined | void;
474
476
  }
475
477
 
478
+ /** Context passed to plugin routes and middleware. */
479
+ export interface PluginRequestContext {
480
+ /** The engine as the calling actor (authorization and tenancy apply). */
481
+ engine: import('../engine/index.js').CriticalPathEngine;
482
+ /** The caller resolved by the router's `getContext`, if any. */
483
+ context?: Record<string, unknown> & { userId?: string };
484
+ url: URL;
485
+ /** Path parameters from the route pattern, e.g. `{ projectId: 'p1' }` for `/reports/:projectId`. */
486
+ params: Record<string, string>;
487
+ }
488
+
489
+ export interface PluginRoute {
490
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
491
+ /** Path below the router's base, with `:name` parameters, e.g. `/reports/:projectId`. */
492
+ path: string;
493
+ handler: (request: Request, ctx: PluginRequestContext) => Response | Promise<Response>;
494
+ }
495
+
496
+ /**
497
+ * Wraps every routed request (after authentication). Call `next()` to continue, or return a
498
+ * `Response` to short-circuit (e.g. rate limiting).
499
+ */
500
+ export type PluginMiddleware = (
501
+ request: Request,
502
+ ctx: Omit<PluginRequestContext, 'params'>,
503
+ next: () => Promise<Response>
504
+ ) => Response | Promise<Response>;
505
+
476
506
  export interface CriticalPathPlugin {
477
507
  id: string;
478
508
  name: string;
479
509
  version: string;
480
510
  description?: string;
481
511
  hooks?: PluginHooks;
482
- customFieldTypes?: CustomFieldDefinition[];
483
- init?: (engine: unknown) => Promise<void> | void;
512
+ /** Additional custom field types projects can use in `customFieldDefinitions`. */
513
+ customFieldTypes?: CustomFieldType[];
514
+ /** Runs once when the engine starts; `engine.ready` resolves after every plugin's `init`. */
515
+ init?: (engine: import('../engine/index.js').CriticalPathEngine) => Promise<void> | void;
516
+ /** HTTP routes served by `@critical-path/server`, matched before built-in routes. */
517
+ routes?: PluginRoute[];
518
+ /** Middleware run by `@critical-path/server` around every routed request. */
519
+ middleware?: PluginMiddleware;
484
520
  }
485
521
 
486
522
  export interface CriticalPathConfig {
@@ -494,7 +530,10 @@ export interface CriticalPathConfig {
494
530
  store?: 'memory' | 'sqlite' | unknown;
495
531
  fileStorage?: FileStorageAdapter;
496
532
  plugins?: CriticalPathPlugin[];
533
+ /** Static webhooks (not stored or editable through the API). */
497
534
  webhooks?: Omit<Webhook, 'id' | 'createdAt'>[];
535
+ /** Delivery behaviour: queue, timeouts, retries, private URL policy, failure callbacks. */
536
+ webhookDelivery?: import('../webhooks/dispatcher.js').WebhookDeliveryOptions;
498
537
  defaultSchedule?: WorkSchedule;
499
538
  initialData?: {
500
539
  projects?: Project[];