@vertesia/tools-sdk 1.5.0-dev.20260725.083715Z → 1.5.0-dev.20260806.125520Z
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/lib/ActivityCollection.d.ts.map +1 -1
- package/lib/ContentTypesCollection.d.ts.map +1 -1
- package/lib/InteractionCollection.d.ts.map +1 -1
- package/lib/RenderingTemplateCollection.d.ts +10 -1
- package/lib/RenderingTemplateCollection.d.ts.map +1 -1
- package/lib/SkillCollection.d.ts.map +1 -1
- package/lib/ToolCollection.d.ts.map +1 -1
- package/lib/ToolRegistry.d.ts.map +1 -1
- package/lib/ToolRegistry.js.map +1 -1
- package/lib/auth.d.ts.map +1 -1
- package/lib/auth.js.map +1 -1
- package/lib/copy-assets.js.map +1 -1
- package/lib/server/app-package.d.ts.map +1 -1
- package/lib/server/app-package.js +48 -0
- package/lib/server/app-package.js.map +1 -1
- package/lib/server/hooks.d.ts +4 -0
- package/lib/server/hooks.d.ts.map +1 -0
- package/lib/server/hooks.js +76 -0
- package/lib/server/hooks.js.map +1 -0
- package/lib/server/types.d.ts +7 -1
- package/lib/server/types.d.ts.map +1 -1
- package/lib/server.d.ts.map +1 -1
- package/lib/server.js +3 -0
- package/lib/server.js.map +1 -1
- package/lib/types.d.ts +67 -1
- package/lib/types.d.ts.map +1 -1
- package/package.json +8 -8
- package/src/server/app-package.test.ts +129 -1
- package/src/server/app-package.ts +59 -0
- package/src/server/hooks.test.ts +158 -0
- package/src/server/hooks.ts +95 -0
- package/src/server/types.ts +14 -1
- package/src/server.ts +3 -0
- package/src/types.ts +85 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vertesia/tools-sdk",
|
|
3
|
-
"version": "1.5.0-dev.
|
|
3
|
+
"version": "1.5.0-dev.20260806.125520Z",
|
|
4
4
|
"description": "Tools SDK - utilities for building remote tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -18,19 +18,19 @@
|
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@types/node": "^24.13.3",
|
|
21
|
-
"typescript": "
|
|
21
|
+
"typescript": "7.0.2",
|
|
22
22
|
"vitest": "^4.1.10",
|
|
23
23
|
"@vertesia/tsconfig": "0.1.0"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
|
-
"hono": "^4.12.
|
|
26
|
+
"hono": "^4.12.34"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"jose": "^6.2.
|
|
29
|
+
"jose": "^6.2.4",
|
|
30
30
|
"zod": "^4.4.3",
|
|
31
|
-
"@
|
|
32
|
-
"@vertesia/common": "1.5.0-dev.
|
|
33
|
-
"@
|
|
31
|
+
"@llumiverse/common": "1.5.0-dev.20260806.124634Z",
|
|
32
|
+
"@vertesia/common": "1.5.0-dev.20260806.125520Z",
|
|
33
|
+
"@vertesia/client": "1.5.0-dev.20260806.125520Z"
|
|
34
34
|
},
|
|
35
35
|
"repository": {
|
|
36
36
|
"type": "git",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"agents",
|
|
47
47
|
"typescript"
|
|
48
48
|
],
|
|
49
|
-
"gitHead": "
|
|
49
|
+
"gitHead": "416423ef4182d8668809604902a871ca79629298",
|
|
50
50
|
"scripts": {
|
|
51
51
|
"lint": "biome lint src",
|
|
52
52
|
"test": "vitest run",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PromptRole } from '@llumiverse/common';
|
|
2
|
-
import { PROCESS_DEFINITION_FORMAT_VERSION, TemplateType } from '@vertesia/common';
|
|
2
|
+
import { PROCESS_DEFINITION_FORMAT_VERSION, SystemRoles, TemplateType } from '@vertesia/common';
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
4
|
import { ContentTypesCollection } from '../ContentTypesCollection.js';
|
|
5
5
|
import { InteractionCollection } from '../InteractionCollection.js';
|
|
@@ -75,6 +75,7 @@ describe('buildAppPackage', () => {
|
|
|
75
75
|
src: '/lib/plugin.js',
|
|
76
76
|
available_in: ['app_portal'],
|
|
77
77
|
},
|
|
78
|
+
hooks: [{ kind: 'lifecycle', name: 'install', handler: async () => undefined }],
|
|
78
79
|
} satisfies ToolServerConfig;
|
|
79
80
|
|
|
80
81
|
const pkg = await buildAppPackage(config, {
|
|
@@ -87,6 +88,7 @@ describe('buildAppPackage', () => {
|
|
|
87
88
|
expect(pkg.processes?.map((process) => process.id)).toEqual(['claims:intake']);
|
|
88
89
|
expect(pkg.dashboards?.map((dashboard) => dashboard.id)).toEqual(['claims:ops']);
|
|
89
90
|
expect(pkg.ui?.src).toBe('https://apps.example.test/lib/plugin.js');
|
|
91
|
+
expect(pkg.hooks).toEqual({ install: '/api/hooks/install' });
|
|
90
92
|
});
|
|
91
93
|
|
|
92
94
|
it('honors package scopes', async () => {
|
|
@@ -110,6 +112,132 @@ describe('buildAppPackage', () => {
|
|
|
110
112
|
expect(pkg.types?.map((type) => type.id)).toEqual(['claim']);
|
|
111
113
|
expect(pkg.interactions).toBeUndefined();
|
|
112
114
|
});
|
|
115
|
+
|
|
116
|
+
it('returns lifecycle and event hook metadata for the hooks scope', async () => {
|
|
117
|
+
const config = {
|
|
118
|
+
interactions: [
|
|
119
|
+
new InteractionCollection({
|
|
120
|
+
name: 'claims',
|
|
121
|
+
interactions: [{ name: 'review', prompts: [] }],
|
|
122
|
+
}),
|
|
123
|
+
],
|
|
124
|
+
hooks: [
|
|
125
|
+
{ kind: 'lifecycle', name: 'install', handler: async () => undefined },
|
|
126
|
+
{ kind: 'lifecycle', name: 'uninstall', handler: async () => undefined },
|
|
127
|
+
{
|
|
128
|
+
kind: 'event',
|
|
129
|
+
name: 'content-updated',
|
|
130
|
+
description: 'Processes updated content.',
|
|
131
|
+
handler: async () => undefined,
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
} satisfies ToolServerConfig;
|
|
135
|
+
|
|
136
|
+
const pkg = await buildAppPackage(config, { scope: 'hooks' });
|
|
137
|
+
|
|
138
|
+
expect(pkg.hooks).toEqual({
|
|
139
|
+
install: '/api/hooks/install',
|
|
140
|
+
uninstall: '/api/hooks/uninstall',
|
|
141
|
+
events: [
|
|
142
|
+
{
|
|
143
|
+
name: 'content-updated',
|
|
144
|
+
path: '/api/hooks/content-updated',
|
|
145
|
+
description: 'Processes updated content.',
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
expect(pkg.interactions).toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('returns subscriptions that reference registered event hooks', async () => {
|
|
153
|
+
const config = {
|
|
154
|
+
hooks: [
|
|
155
|
+
{
|
|
156
|
+
kind: 'event',
|
|
157
|
+
name: 'content-updated',
|
|
158
|
+
handler: async () => undefined,
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
subscriptions: [
|
|
162
|
+
{
|
|
163
|
+
id: 'content-updated',
|
|
164
|
+
name: 'Content updated',
|
|
165
|
+
description: 'Refresh app-owned projections after content changes.',
|
|
166
|
+
hook: 'content-updated',
|
|
167
|
+
filter: { action: ['update'], resource_type: ['content_object'] },
|
|
168
|
+
run_as_role: SystemRoles.automation,
|
|
169
|
+
priority: 'normal',
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
} satisfies ToolServerConfig;
|
|
173
|
+
|
|
174
|
+
const pkg = await buildAppPackage(config, { scope: 'subscriptions' });
|
|
175
|
+
|
|
176
|
+
expect(pkg.subscriptions).toEqual(config.subscriptions);
|
|
177
|
+
expect(pkg.hooks).toBeUndefined();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('rejects subscriptions that reference missing or lifecycle hooks', async () => {
|
|
181
|
+
const missingHook = {
|
|
182
|
+
subscriptions: [
|
|
183
|
+
{
|
|
184
|
+
id: 'content-updated',
|
|
185
|
+
name: 'Content updated',
|
|
186
|
+
hook: 'content-updated',
|
|
187
|
+
filter: { action: ['update'] },
|
|
188
|
+
run_as_role: SystemRoles.automation,
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
} satisfies ToolServerConfig;
|
|
192
|
+
await expect(buildAppPackage(missingHook, { scope: 'subscriptions' })).rejects.toThrow(
|
|
193
|
+
/references missing hook 'content-updated'/,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const lifecycleHook = {
|
|
197
|
+
hooks: [{ kind: 'lifecycle', name: 'install', handler: async () => undefined }],
|
|
198
|
+
subscriptions: [
|
|
199
|
+
{
|
|
200
|
+
id: 'on-install',
|
|
201
|
+
name: 'On install',
|
|
202
|
+
hook: 'install',
|
|
203
|
+
filter: { action: ['installed'] },
|
|
204
|
+
run_as_role: SystemRoles.automation,
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
} satisfies ToolServerConfig;
|
|
208
|
+
await expect(buildAppPackage(lifecycleHook, { scope: 'subscriptions' })).rejects.toThrow(
|
|
209
|
+
/must reference an event hook/,
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('rejects duplicate or non-kebab-case subscription ids', async () => {
|
|
214
|
+
const eventHook = {
|
|
215
|
+
kind: 'event' as const,
|
|
216
|
+
name: 'content-updated',
|
|
217
|
+
handler: async () => undefined,
|
|
218
|
+
};
|
|
219
|
+
const subscription = {
|
|
220
|
+
id: 'content-updated',
|
|
221
|
+
name: 'Content updated',
|
|
222
|
+
hook: 'content-updated',
|
|
223
|
+
filter: { action: ['update'] },
|
|
224
|
+
run_as_role: SystemRoles.automation,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
await expect(
|
|
228
|
+
buildAppPackage(
|
|
229
|
+
{ hooks: [eventHook], subscriptions: [subscription, { ...subscription }] },
|
|
230
|
+
{ scope: 'subscriptions' },
|
|
231
|
+
),
|
|
232
|
+
).rejects.toThrow(/Duplicate app event subscription id 'content-updated'/);
|
|
233
|
+
|
|
234
|
+
await expect(
|
|
235
|
+
buildAppPackage(
|
|
236
|
+
{ hooks: [eventHook], subscriptions: [{ ...subscription, id: 'Content Updated' }] },
|
|
237
|
+
{ scope: 'subscriptions' },
|
|
238
|
+
),
|
|
239
|
+
).rejects.toThrow(/must be kebab case/);
|
|
240
|
+
});
|
|
113
241
|
});
|
|
114
242
|
|
|
115
243
|
describe('buildAppPackage type identity', () => {
|
|
@@ -158,6 +158,57 @@ const builders: Record<Exclude<AppPackageScope, 'all'>, AppPackageBuilder> = {
|
|
|
158
158
|
}
|
|
159
159
|
pkg.activities = allActivities;
|
|
160
160
|
},
|
|
161
|
+
async hooks(pkg: AppPackage, config: ToolServerConfig) {
|
|
162
|
+
const prefix = config.prefix || '/api';
|
|
163
|
+
const lifecycleHooks = new Set(
|
|
164
|
+
config.hooks?.filter((hook) => hook.kind === 'lifecycle').map((hook) => hook.name),
|
|
165
|
+
);
|
|
166
|
+
const eventHooks = (config.hooks ?? [])
|
|
167
|
+
.filter((hook) => hook.kind === 'event')
|
|
168
|
+
.map((hook) => ({
|
|
169
|
+
name: hook.name,
|
|
170
|
+
path: `${prefix}/hooks/${hook.name}`,
|
|
171
|
+
...(hook.description ? { description: hook.description } : {}),
|
|
172
|
+
}));
|
|
173
|
+
if (lifecycleHooks.size > 0 || eventHooks.length > 0) {
|
|
174
|
+
pkg.hooks = {
|
|
175
|
+
...(lifecycleHooks.has('install') && { install: `${prefix}/hooks/install` }),
|
|
176
|
+
...(lifecycleHooks.has('uninstall') && { uninstall: `${prefix}/hooks/uninstall` }),
|
|
177
|
+
...(eventHooks.length > 0 && { events: eventHooks }),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
async subscriptions(pkg: AppPackage, config: ToolServerConfig) {
|
|
182
|
+
const subscriptions = config.subscriptions ?? [];
|
|
183
|
+
const seenIds = new Set<string>();
|
|
184
|
+
const hooksByName = new Map((config.hooks ?? []).map((hook) => [hook.name, hook]));
|
|
185
|
+
|
|
186
|
+
for (const subscription of subscriptions) {
|
|
187
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(subscription.id)) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`Invalid app event subscription id '${subscription.id}'. Subscription ids must be kebab case.`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
if (seenIds.has(subscription.id)) {
|
|
193
|
+
throw new Error(`Duplicate app event subscription id '${subscription.id}'.`);
|
|
194
|
+
}
|
|
195
|
+
seenIds.add(subscription.id);
|
|
196
|
+
|
|
197
|
+
const hook = hooksByName.get(subscription.hook);
|
|
198
|
+
if (!hook) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`App event subscription '${subscription.id}' references missing hook '${subscription.hook}'.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (hook.kind !== 'event') {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`App event subscription '${subscription.id}' must reference an event hook; '${subscription.hook}' is a lifecycle hook.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
pkg.subscriptions = subscriptions.map((subscription) => ({ ...subscription }));
|
|
211
|
+
},
|
|
161
212
|
};
|
|
162
213
|
|
|
163
214
|
function normalizeScopes(scope: BuildAppPackageOptions['scope']): Set<AppPackageScope> {
|
|
@@ -184,6 +235,8 @@ export async function buildAppPackage(
|
|
|
184
235
|
await builders.ui(pkg, config, options);
|
|
185
236
|
await builders.settings(pkg, config, options);
|
|
186
237
|
await builders.activities(pkg, config, options);
|
|
238
|
+
await builders.hooks(pkg, config, options);
|
|
239
|
+
await builders.subscriptions(pkg, config, options);
|
|
187
240
|
} else {
|
|
188
241
|
if (scopes.has('tools')) {
|
|
189
242
|
await builders.tools(pkg, config, options);
|
|
@@ -218,6 +271,12 @@ export async function buildAppPackage(
|
|
|
218
271
|
if (scopes.has('activities')) {
|
|
219
272
|
await builders.activities(pkg, config, options);
|
|
220
273
|
}
|
|
274
|
+
if (scopes.has('hooks')) {
|
|
275
|
+
await builders.hooks(pkg, config, options);
|
|
276
|
+
}
|
|
277
|
+
if (scopes.has('subscriptions')) {
|
|
278
|
+
await builders.subscriptions(pkg, config, options);
|
|
279
|
+
}
|
|
221
280
|
}
|
|
222
281
|
|
|
223
282
|
return pkg;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { AuthTokenPayload, PlatformEvent } from '@vertesia/common';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { AppEventHookPayload, AppLifecycleHookContext } from '../types.js';
|
|
4
|
+
|
|
5
|
+
const authorizeMock = vi.hoisted(() => vi.fn());
|
|
6
|
+
|
|
7
|
+
vi.mock('../auth.js', () => ({ authorize: authorizeMock }));
|
|
8
|
+
|
|
9
|
+
import { createToolServer } from '../server.js';
|
|
10
|
+
|
|
11
|
+
const event = {
|
|
12
|
+
event_id: 'event-1',
|
|
13
|
+
root_event_id: 'event-1',
|
|
14
|
+
hop_count: 0,
|
|
15
|
+
event_category: 'content',
|
|
16
|
+
action: 'updated',
|
|
17
|
+
resource_type: 'content_object',
|
|
18
|
+
resource_id: 'object-1',
|
|
19
|
+
account_id: 'account-1',
|
|
20
|
+
project_id: 'project-1',
|
|
21
|
+
tenant_id: 'account-1_project-1',
|
|
22
|
+
timestamp: '2026-08-05T10:00:00.000Z',
|
|
23
|
+
source: 'zeno-server',
|
|
24
|
+
} satisfies PlatformEvent;
|
|
25
|
+
|
|
26
|
+
const eventPayload = {
|
|
27
|
+
event,
|
|
28
|
+
delivery: {
|
|
29
|
+
id: 'delivery-1',
|
|
30
|
+
subscription_id: 'subscription-1',
|
|
31
|
+
attempt: 1,
|
|
32
|
+
},
|
|
33
|
+
} satisfies AppEventHookPayload;
|
|
34
|
+
|
|
35
|
+
describe('app hooks', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
authorizeMock.mockReset();
|
|
38
|
+
authorizeMock.mockResolvedValue({
|
|
39
|
+
token: 'project-token',
|
|
40
|
+
payload: { project: { id: 'project-1' } } as AuthTokenPayload,
|
|
41
|
+
getClient: vi.fn(),
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('executes a registered hook with authenticated context and metadata', async () => {
|
|
46
|
+
const install = vi.fn(async (_context: AppLifecycleHookContext) => ({ message: 'installed' }));
|
|
47
|
+
const app = createToolServer({
|
|
48
|
+
disableHtml: true,
|
|
49
|
+
hooks: [{ kind: 'lifecycle', name: 'install', handler: install }],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const response = await app.request('/api/hooks/install', {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { Authorization: 'Bearer project-token', 'Content-Type': 'application/json' },
|
|
55
|
+
body: JSON.stringify({ metadata: { app_install_id: 'install-1', app_settings: { locale: 'en' } } }),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(response.status).toBe(200);
|
|
59
|
+
await expect(response.json()).resolves.toEqual({ ok: true, message: 'installed' });
|
|
60
|
+
expect(install).toHaveBeenCalledWith(
|
|
61
|
+
expect.objectContaining({
|
|
62
|
+
token: 'project-token',
|
|
63
|
+
metadata: { app_install_id: 'install-1', app_settings: { locale: 'en' } },
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns 404 when the hook is not registered', async () => {
|
|
69
|
+
const app = createToolServer({ disableHtml: true });
|
|
70
|
+
|
|
71
|
+
const response = await app.request('/api/hooks/uninstall', {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { Authorization: 'Bearer project-token', 'Content-Type': 'application/json' },
|
|
74
|
+
body: '{}',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(response.status).toBe(404);
|
|
78
|
+
expect(authorizeMock).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('executes an event hook with the canonical delivery envelope and authenticated context', async () => {
|
|
82
|
+
const contentUpdated = vi.fn(async (_payload: AppEventHookPayload) => ({ message: 'processed' }));
|
|
83
|
+
const app = createToolServer({
|
|
84
|
+
disableHtml: true,
|
|
85
|
+
hooks: [
|
|
86
|
+
{
|
|
87
|
+
kind: 'event',
|
|
88
|
+
name: 'content-updated',
|
|
89
|
+
description: 'Processes updated content.',
|
|
90
|
+
handler: contentUpdated,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const response = await app.request('/api/hooks/content-updated', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { Authorization: 'Bearer project-token', 'Content-Type': 'application/json' },
|
|
98
|
+
body: JSON.stringify(eventPayload),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(response.status).toBe(200);
|
|
102
|
+
await expect(response.json()).resolves.toEqual({ ok: true, message: 'processed' });
|
|
103
|
+
expect(contentUpdated).toHaveBeenCalledWith(
|
|
104
|
+
eventPayload,
|
|
105
|
+
expect.objectContaining({ token: 'project-token', payload: { project: { id: 'project-1' } } }),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('rejects malformed event hook payloads before authentication', async () => {
|
|
110
|
+
const handler = vi.fn(async () => undefined);
|
|
111
|
+
const app = createToolServer({
|
|
112
|
+
disableHtml: true,
|
|
113
|
+
hooks: [{ kind: 'event', name: 'content-updated', handler }],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const response = await app.request('/api/hooks/content-updated', {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: { Authorization: 'Bearer project-token', 'Content-Type': 'application/json' },
|
|
119
|
+
body: JSON.stringify({ event: eventPayload.event }),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(response.status).toBe(400);
|
|
123
|
+
expect(handler).not.toHaveBeenCalled();
|
|
124
|
+
expect(authorizeMock).not.toHaveBeenCalled();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it.each(['install', 'uninstall'])('reserves the lifecycle hook name %s', (name) => {
|
|
128
|
+
expect(() =>
|
|
129
|
+
createToolServer({
|
|
130
|
+
disableHtml: true,
|
|
131
|
+
hooks: [{ kind: 'event', name, handler: async () => undefined }],
|
|
132
|
+
}),
|
|
133
|
+
).toThrow(`App event hook name is reserved: ${name}`);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('rejects event hook names that are not safe path segments', () => {
|
|
137
|
+
expect(() =>
|
|
138
|
+
createToolServer({
|
|
139
|
+
disableHtml: true,
|
|
140
|
+
hooks: [{ kind: 'event', name: 'content/updated', handler: async () => undefined }],
|
|
141
|
+
}),
|
|
142
|
+
).toThrow('Invalid app event hook name: content/updated');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('rejects duplicate lifecycle hook registrations', () => {
|
|
146
|
+
const handler = vi.fn(async () => undefined);
|
|
147
|
+
|
|
148
|
+
expect(() =>
|
|
149
|
+
createToolServer({
|
|
150
|
+
disableHtml: true,
|
|
151
|
+
hooks: [
|
|
152
|
+
{ kind: 'lifecycle', name: 'install', handler },
|
|
153
|
+
{ kind: 'lifecycle', name: 'install', handler },
|
|
154
|
+
],
|
|
155
|
+
}),
|
|
156
|
+
).toThrow('Duplicate app hook: install');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { AppEventHookPayloadSchema } from '@vertesia/common/api-schemas';
|
|
2
|
+
import type { Context, Hono } from 'hono';
|
|
3
|
+
import { HTTPException } from 'hono/http-exception';
|
|
4
|
+
import { authorize } from '../auth.js';
|
|
5
|
+
import type {
|
|
6
|
+
AppEventHookDefinition,
|
|
7
|
+
AppEventHookPayload,
|
|
8
|
+
AppLifecycleHookContext,
|
|
9
|
+
AppLifecycleHookDefinition,
|
|
10
|
+
AppLifecycleHookName,
|
|
11
|
+
AppLifecycleHookPayload,
|
|
12
|
+
AppLifecycleHookResult,
|
|
13
|
+
} from '../types.js';
|
|
14
|
+
import type { ToolContext, ToolServerConfig } from './types.js';
|
|
15
|
+
|
|
16
|
+
const HOOK_NAMES = new Set<AppLifecycleHookName>(['install', 'uninstall']);
|
|
17
|
+
const EVENT_HOOK_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
18
|
+
|
|
19
|
+
export function createHooksRoute(app: Hono, basePath: string, config: ToolServerConfig) {
|
|
20
|
+
const hooks = new Map<string, AppLifecycleHookDefinition | AppEventHookDefinition>();
|
|
21
|
+
for (const hook of config.hooks ?? []) {
|
|
22
|
+
if (hook.kind === 'lifecycle' && !HOOK_NAMES.has(hook.name)) {
|
|
23
|
+
throw new Error(`Unknown app lifecycle hook: ${hook.name}`);
|
|
24
|
+
}
|
|
25
|
+
if (hook.kind === 'event' && !EVENT_HOOK_NAME_PATTERN.test(hook.name)) {
|
|
26
|
+
throw new Error(`Invalid app event hook name: ${hook.name}`);
|
|
27
|
+
}
|
|
28
|
+
if (hook.kind === 'event' && HOOK_NAMES.has(hook.name as AppLifecycleHookName)) {
|
|
29
|
+
throw new Error(`App event hook name is reserved: ${hook.name}`);
|
|
30
|
+
}
|
|
31
|
+
if (hooks.has(hook.name)) {
|
|
32
|
+
throw new Error(`Duplicate app hook: ${hook.name}`);
|
|
33
|
+
}
|
|
34
|
+
hooks.set(hook.name, hook);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
app.post(`${basePath}/:name`, async (c: Context) => {
|
|
38
|
+
const name = c.req.param('name') ?? '';
|
|
39
|
+
const hook = hooks.get(name);
|
|
40
|
+
if (!hook) {
|
|
41
|
+
throw new HTTPException(404, { message: `App hook is not registered: ${name}` });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const requestContext = c as unknown as ToolContext;
|
|
45
|
+
if (hook.kind === 'event') {
|
|
46
|
+
return executeEventHook(c, requestContext, hook);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const payload = parseHookPayload(requestContext.requestBody);
|
|
50
|
+
const metadata = payload.metadata ?? {};
|
|
51
|
+
const session = await authorize(c, metadata.endpoints, { toolName: `hook:${name}` });
|
|
52
|
+
const context: AppLifecycleHookContext = {
|
|
53
|
+
token: session.token,
|
|
54
|
+
payload: session.payload,
|
|
55
|
+
getClient: () => session.getClient(),
|
|
56
|
+
metadata,
|
|
57
|
+
};
|
|
58
|
+
const result = await hook.handler(context);
|
|
59
|
+
|
|
60
|
+
return c.json({ ok: true, ...(result ?? {}) } satisfies { ok: true } & AppLifecycleHookResult);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function executeEventHook(c: Context, requestContext: ToolContext, hook: AppEventHookDefinition) {
|
|
65
|
+
const eventPayload = parseEventHookPayload(requestContext.requestBody);
|
|
66
|
+
const session = await authorize(c, undefined, { toolName: `hook:${hook.name}` });
|
|
67
|
+
const context = {
|
|
68
|
+
token: session.token,
|
|
69
|
+
payload: session.payload,
|
|
70
|
+
getClient: () => session.getClient(),
|
|
71
|
+
};
|
|
72
|
+
const result = await hook.handler(eventPayload, context);
|
|
73
|
+
|
|
74
|
+
return c.json({ ok: true, ...(result ?? {}) } satisfies { ok: true } & AppLifecycleHookResult);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseHookPayload(body: unknown): AppLifecycleHookPayload {
|
|
78
|
+
if (body === undefined) return {};
|
|
79
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
80
|
+
throw new HTTPException(400, { message: 'Invalid lifecycle hook payload' });
|
|
81
|
+
}
|
|
82
|
+
const metadata = (body as AppLifecycleHookPayload).metadata;
|
|
83
|
+
if (metadata !== undefined && (!metadata || typeof metadata !== 'object' || Array.isArray(metadata))) {
|
|
84
|
+
throw new HTTPException(400, { message: 'Invalid lifecycle hook metadata' });
|
|
85
|
+
}
|
|
86
|
+
return { metadata };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseEventHookPayload(body: unknown): AppEventHookPayload {
|
|
90
|
+
const result = AppEventHookPayloadSchema.safeParse(body);
|
|
91
|
+
if (!result.success) {
|
|
92
|
+
throw new HTTPException(400, { message: 'Invalid event hook payload' });
|
|
93
|
+
}
|
|
94
|
+
return result.data;
|
|
95
|
+
}
|
package/src/server/types.ts
CHANGED
|
@@ -14,7 +14,12 @@ import type { InteractionCollection } from '../InteractionCollection.js';
|
|
|
14
14
|
import type { RenderingTemplateCollection } from '../RenderingTemplateCollection.js';
|
|
15
15
|
import type { SkillCollection } from '../SkillCollection.js';
|
|
16
16
|
import type { ToolCollection } from '../ToolCollection.js';
|
|
17
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
AppEventSubscriptionDefinition,
|
|
19
|
+
AppHookDefinition,
|
|
20
|
+
MCPConnectionDetails,
|
|
21
|
+
ToolExecutionPayload,
|
|
22
|
+
} from '../types.js';
|
|
18
23
|
|
|
19
24
|
/**
|
|
20
25
|
* Extended context with parsed payload for tool/skill execution
|
|
@@ -26,6 +31,8 @@ export interface ToolContext extends Context {
|
|
|
26
31
|
toolUseId?: string;
|
|
27
32
|
/** The tool_use.tool_name from the payload */
|
|
28
33
|
toolName?: string;
|
|
34
|
+
/** Parsed JSON body for non-tool routes such as lifecycle hooks. */
|
|
35
|
+
requestBody?: unknown;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
/**
|
|
@@ -90,6 +97,12 @@ export interface ToolServerConfig {
|
|
|
90
97
|
*/
|
|
91
98
|
mcpProviders?: MCPProviderConfig[];
|
|
92
99
|
|
|
100
|
+
/** Authenticated application hooks aggregated from active application modules. */
|
|
101
|
+
hooks?: AppHookDefinition[];
|
|
102
|
+
|
|
103
|
+
/** App-owned subscriptions whose delivery target is a registered event hook. */
|
|
104
|
+
subscriptions?: AppEventSubscriptionDefinition[];
|
|
105
|
+
|
|
93
106
|
/**
|
|
94
107
|
* A JSON schema defining settings for the application using this server
|
|
95
108
|
*/
|
package/src/server.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { createActivitiesRoute } from './server/activities.js';
|
|
|
6
6
|
import { createPackageRoute } from './server/app-package.js';
|
|
7
7
|
import { createContentTypesRoute } from './server/content-types.js';
|
|
8
8
|
import { createDashboardsRoute } from './server/dashboards.js';
|
|
9
|
+
import { createHooksRoute } from './server/hooks.js';
|
|
9
10
|
import { createInteractionsRoute } from './server/interactions.js';
|
|
10
11
|
import { createMcpRoute } from './server/mcp.js';
|
|
11
12
|
import { createProcessesRoute } from './server/processes.js';
|
|
@@ -68,6 +69,7 @@ export function createToolServer(config: ToolServerConfig): Hono {
|
|
|
68
69
|
try {
|
|
69
70
|
const text = await c.req.text();
|
|
70
71
|
const body = JSON.parse(text);
|
|
72
|
+
ctx.requestBody = body;
|
|
71
73
|
const result = ToolExecutionPayloadSchema.safeParse(body);
|
|
72
74
|
if (result.success) {
|
|
73
75
|
ctx.payload = result.data as ToolExecutionPayload;
|
|
@@ -112,6 +114,7 @@ export function createToolServer(config: ToolServerConfig): Hono {
|
|
|
112
114
|
});
|
|
113
115
|
|
|
114
116
|
createPackageRoute(app, `${prefix}/package`, config);
|
|
117
|
+
createHooksRoute(app, `${prefix}/hooks`, config);
|
|
115
118
|
createToolsRoute(app, `${prefix}/tools`, config);
|
|
116
119
|
createSkillsRoute(app, `${prefix}/skills`, config);
|
|
117
120
|
createWidgetsRoute(app, `${prefix}/widgets`, config);
|