@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.
- package/CHANGELOG.md +50 -0
- package/README.md +15 -0
- package/dist/domain/custom-fields.d.ts +7 -2
- package/dist/domain/custom-fields.d.ts.map +1 -1
- package/dist/domain/custom-fields.js +23 -1
- package/dist/domain/custom-fields.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/engine/index.d.ts +30 -2
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +133 -77
- 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/plugins/index.d.ts +13 -3
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +44 -15
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/plugins.test.d.ts +2 -0
- package/dist/plugins/plugins.test.d.ts.map +1 -0
- package/dist/plugins/plugins.test.js +137 -0
- package/dist/plugins/plugins.test.js.map +1 -0
- package/dist/schemas/index.d.ts +167 -121
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +15 -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 +42 -8
- package/dist/store/sqlite.js.map +1 -1
- package/dist/types/index.d.ts +67 -6
- 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/custom-fields.ts +43 -2
- package/src/domain/events.ts +42 -3
- package/src/engine/index.ts +170 -84
- package/src/index.ts +1 -0
- package/src/plugins/index.ts +51 -16
- package/src/plugins/plugins.test.ts +161 -0
- package/src/schemas/index.ts +19 -1
- package/src/schemas/schemas.test.ts +6 -3
- 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 +54 -7
- package/src/types/index.ts +72 -33
- 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
|
@@ -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
|
+
});
|
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
|
|
@@ -64,7 +65,8 @@ export const CustomFieldDefinitionSchema = strictObject({
|
|
|
64
65
|
id: z.string(),
|
|
65
66
|
key: nonEmpty,
|
|
66
67
|
label: z.string(),
|
|
67
|
-
type
|
|
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
|
|
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
|
@@ -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
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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;
|
package/src/types/index.ts
CHANGED
|
@@ -88,7 +88,8 @@ export interface CustomFieldDefinition {
|
|
|
88
88
|
id: string;
|
|
89
89
|
key: string;
|
|
90
90
|
label: string;
|
|
91
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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[];
|