@slates/provider-handler 1.0.0-rc.42 → 1.0.0-rc.44

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/src/spec.test.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isServiceError } from '@lowerdeck/error';
2
+ import { slatesTriggerGroup } from '@slates/proto';
2
3
  import { describe, expect, it } from 'vitest';
3
4
  import { z } from 'zod';
4
5
  import {
@@ -8,11 +9,14 @@ import {
8
9
  getExposedTriggerGroups,
9
10
  getMappableAction,
10
11
  getTriggersForGroup,
12
+ getWebhookDeliveryConfig,
13
+ getWebhookManualRegistration,
11
14
  isMappableTrigger,
12
15
  mapAction,
13
16
  mapAdapter,
14
17
  mapAuthMethod,
15
- mapToolCategory
18
+ mapToolCategory,
19
+ mapTriggerGroup
16
20
  } from './spec';
17
21
 
18
22
  let baseAction = {
@@ -202,6 +206,92 @@ describe('adapter exposure', () => {
202
206
  });
203
207
  });
204
208
 
209
+ describe('webhook registration config', () => {
210
+ let config = {
211
+ userConfigSchema: z.object({ secret: z.string().describe('Signing secret') }),
212
+ fullConfigSchema: z.object({ secret: z.string(), endpoint: z.string() }),
213
+ setup: async () => ({ webhookSetupDocument: '', partialWebhookRegistrationPayload: {} }),
214
+ finish: async () => ({ webhookRegistrationPayload: {} }),
215
+ authenticate: async () => ({ routingKeys: [] })
216
+ };
217
+ let schemas = {
218
+ userConfigSchema: {
219
+ type: 'object',
220
+ properties: { secret: { type: 'string', description: 'Signing secret' } },
221
+ required: ['secret']
222
+ },
223
+ fullConfigSchema: {
224
+ type: 'object',
225
+ properties: { secret: { type: 'string' }, endpoint: { type: 'string' } },
226
+ required: ['secret', 'endpoint']
227
+ }
228
+ };
229
+ let autoRegistration = {
230
+ webhookTargetList: async () => ({ targets: [], nextPageToken: null }),
231
+ webhookRegister: async () => ({
232
+ webhookRegistrationIdentifier: 'reg',
233
+ webhookRegistrationPayload: {}
234
+ }),
235
+ webhookUnregister: async () => {}
236
+ };
237
+ let group = (webhook: Record<string, unknown>): any => ({
238
+ key: 'group',
239
+ name: 'Group',
240
+ source: 'webhook',
241
+ webhook: { process: async () => ({ events: [] }), ...webhook }
242
+ });
243
+ let auto = group({ autoRegistration });
244
+ let manual = group({ manualRegistration: config });
245
+ let configured = group({ autoRegistration, deliveryConfig: config });
246
+
247
+ it('keeps plain automatic and manual groups serialised as before', () => {
248
+ expect(mapTriggerGroup(auto).invocation).toEqual({
249
+ type: 'webhook',
250
+ registration: { mode: 'auto' }
251
+ });
252
+ expect(mapTriggerGroup(manual).invocation).toMatchObject({
253
+ type: 'webhook',
254
+ registration: { mode: 'manual', ...schemas }
255
+ });
256
+ expect(
257
+ Object.keys((mapTriggerGroup(manual).invocation as any).registration).sort()
258
+ ).toEqual(['fullConfigSchema', 'mode', 'userConfigSchema']);
259
+ });
260
+
261
+ it('advertises the delivery config of an automatic group', () => {
262
+ let mapped = mapTriggerGroup(configured);
263
+ expect(mapped.invocation).toMatchObject({
264
+ type: 'webhook',
265
+ registration: { mode: 'auto', deliveryConfig: schemas }
266
+ });
267
+ expect(slatesTriggerGroup.parse(mapped).invocation).toEqual(mapped.invocation);
268
+ });
269
+
270
+ it('authenticates deliveries only for automatic groups with a delivery config', () => {
271
+ expect(getWebhookDeliveryConfig(configured)).toBe(config);
272
+ for (let unsupported of [
273
+ auto,
274
+ manual,
275
+ { key: 'group', name: 'Group', source: 'polling' } as any
276
+ ]) {
277
+ expect(() => getWebhookDeliveryConfig(unsupported)).toThrow(
278
+ 'Trigger group does not support webhook delivery authentication: group'
279
+ );
280
+ }
281
+ });
282
+
283
+ it('serves setup and finish for manual groups and automatic groups with a delivery config', () => {
284
+ expect(getWebhookManualRegistration(manual)).toBe(config);
285
+ expect(getWebhookManualRegistration(configured)).toBe(config);
286
+ expect(() => getWebhookManualRegistration(auto)).toThrow(
287
+ 'Trigger group does not support manual webhook registration: group'
288
+ );
289
+ expect(() =>
290
+ getWebhookManualRegistration({ key: 'poll', name: 'Poll', source: 'polling' } as any)
291
+ ).toThrow('Trigger group does not support manual webhook registration: poll');
292
+ });
293
+ });
294
+
205
295
  describe('mapAuthMethod', () => {
206
296
  let slate: any = { spec: { auth: { outputSchema: z.object({ token: z.string() }) } } };
207
297
  let oauthMethod: any = {
package/src/spec.ts CHANGED
@@ -266,7 +266,11 @@ export let getWebhookAutoRegistration = <ConfigType extends {}, AuthType extends
266
266
  export let getWebhookManualRegistration = <ConfigType extends {}, AuthType extends {}>(
267
267
  group: ReturnType<typeof getTriggerGroup<ConfigType, AuthType>>
268
268
  ) => {
269
- if (group.source !== 'webhook' || !group.webhook?.manualRegistration) {
269
+ let registration =
270
+ group.source === 'webhook'
271
+ ? (group.webhook?.manualRegistration ?? group.webhook?.deliveryConfig)
272
+ : undefined;
273
+ if (!registration) {
270
274
  throw new ServiceError(
271
275
  badRequestError({
272
276
  message: `Trigger group does not support manual webhook registration: ${group.key}`
@@ -274,9 +278,31 @@ export let getWebhookManualRegistration = <ConfigType extends {}, AuthType exten
274
278
  );
275
279
  }
276
280
 
277
- return group.webhook.manualRegistration;
281
+ return registration;
278
282
  };
279
283
 
284
+ export let getWebhookDeliveryConfig = <ConfigType extends {}, AuthType extends {}>(
285
+ group: ReturnType<typeof getTriggerGroup<ConfigType, AuthType>>
286
+ ) => {
287
+ if (group.source !== 'webhook' || !group.webhook?.deliveryConfig) {
288
+ throw new ServiceError(
289
+ badRequestError({
290
+ message: `Trigger group does not support webhook delivery authentication: ${group.key}`
291
+ })
292
+ );
293
+ }
294
+
295
+ return group.webhook.deliveryConfig;
296
+ };
297
+
298
+ let mapWebhookConfigSchemas = (config: {
299
+ userConfigSchema: z.ZodType<any>;
300
+ fullConfigSchema: z.ZodType<any>;
301
+ }) => ({
302
+ userConfigSchema: toJsonSchema(config.userConfigSchema),
303
+ fullConfigSchema: toJsonSchema(config.fullConfigSchema)
304
+ });
305
+
280
306
  export let mapToolCategory = (category: SlateToolCategory): SlatesToolCategory => ({
281
307
  id: category.key,
282
308
  name: category.name,
@@ -307,6 +333,9 @@ export let mapTriggerGroup = <ConfigType extends {}, AuthType extends {}>(
307
333
  name: group.name,
308
334
  description: group.description,
309
335
  metadata: group.metadata,
336
+ ...(group.selectionSchema
337
+ ? { selectionSchema: toJsonSchema(group.selectionSchema, 'input') }
338
+ : {}),
310
339
  invocation:
311
340
  group.source === 'polling'
312
341
  ? {
@@ -317,16 +346,25 @@ export let mapTriggerGroup = <ConfigType extends {}, AuthType extends {}>(
317
346
  ? { type: 'gateway' }
318
347
  : {
319
348
  type: 'webhook',
349
+ ...(group.webhook?.expandWebhook ? { supportsExpansion: true } : {}),
350
+ ...(group.webhook?.autoRegistration?.webhookRenew
351
+ ? { supportsRenewal: true }
352
+ : {}),
353
+ ...(group.webhook?.reconcileIntervalSeconds !== undefined
354
+ ? { reconcileIntervalSeconds: group.webhook.reconcileIntervalSeconds }
355
+ : {}),
320
356
  registration: group.webhook?.manualRegistration
321
357
  ? {
322
358
  mode: 'manual',
323
- userConfigSchema: toJsonSchema(
324
- group.webhook.manualRegistration.userConfigSchema
325
- ),
326
- fullConfigSchema: toJsonSchema(
327
- group.webhook.manualRegistration.fullConfigSchema
328
- )
359
+ ...mapWebhookConfigSchemas(group.webhook.manualRegistration)
360
+ }
361
+ : {
362
+ mode: 'auto',
363
+ ...(group.webhook?.deliveryConfig
364
+ ? {
365
+ deliveryConfig: mapWebhookConfigSchemas(group.webhook.deliveryConfig)
366
+ }
367
+ : {})
329
368
  }
330
- : { mode: 'auto' }
331
369
  }
332
370
  });
package/src/validation.ts CHANGED
@@ -26,8 +26,9 @@ export let validate = <T>(
26
26
  return result.data;
27
27
  };
28
28
 
29
- export let toJsonSchema = (schema: z.ZodType<any>) =>
29
+ export let toJsonSchema = (schema: z.ZodType<any>, io: 'input' | 'output' = 'output') =>
30
30
  schema.toJSONSchema({
31
+ io,
31
32
  unrepresentable: 'any',
32
33
  override: ctx => {
33
34
  let def = ctx.zodSchema._zod.def;
@@ -0,0 +1,179 @@
1
+ import { SLATES_PROTOCOL_VERSION, SlatesProviderProtoHandlerManager } from '@slates/proto';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { z } from 'zod';
4
+ import { createProviderHandler } from './index';
5
+
6
+ type Authentication = { routingKeys?: string[]; rejected?: { status: number } };
7
+
8
+ let createManager = async (authenticate: (ctx: any) => Promise<Authentication>) => {
9
+ let contexts: {
10
+ auth: unknown;
11
+ config: unknown;
12
+ deliveryConfig: unknown;
13
+ request: Request;
14
+ }[] = [];
15
+ let autoRegistration = {
16
+ webhookTargetList: async () => ({ targets: [], nextPageToken: null }),
17
+ webhookRegister: async () => ({
18
+ webhookRegistrationIdentifier: 'reg',
19
+ webhookRegistrationPayload: {}
20
+ }),
21
+ webhookUnregister: async () => {}
22
+ };
23
+ let deliveryConfig = {
24
+ userConfigSchema: z.object({}),
25
+ fullConfigSchema: z.object({}),
26
+ setup: async () => ({ webhookSetupDocument: '', partialWebhookRegistrationPayload: {} }),
27
+ finish: async () => ({ webhookRegistrationPayload: {} }),
28
+ authenticate: async (ctx: any) => {
29
+ contexts.push({
30
+ auth: ctx.auth,
31
+ config: ctx.config,
32
+ deliveryConfig: ctx.input.deliveryConfig,
33
+ request: ctx.input.request
34
+ });
35
+ return authenticate(ctx);
36
+ }
37
+ };
38
+ let group = (key: string, webhook: Record<string, unknown>) => ({
39
+ key,
40
+ name: key,
41
+ description: undefined,
42
+ metadata: {},
43
+ source: 'webhook',
44
+ webhook: { process: async () => ({ events: [] }), ...webhook }
45
+ });
46
+ let slate = {
47
+ spec: { key: 'test', name: 'Test' },
48
+ adapters: [],
49
+ actions: [],
50
+ triggerGroups: [
51
+ group('configured', { autoRegistration, deliveryConfig }),
52
+ group('auto', { autoRegistration }),
53
+ group('manual', { manualRegistration: deliveryConfig }),
54
+ { key: 'poll', name: 'poll', source: 'polling', polling: { intervalSeconds: 600 } }
55
+ ]
56
+ };
57
+ let manager = await createProviderHandler(slate as any, []).run();
58
+
59
+ await SlatesProviderProtoHandlerManager.handleInput(manager, {
60
+ jsonrpc: '2.0',
61
+ method: 'slates/hello',
62
+ params: { protocol: SLATES_PROTOCOL_VERSION }
63
+ });
64
+ await SlatesProviderProtoHandlerManager.handleInput(manager, {
65
+ jsonrpc: '2.0',
66
+ method: 'slates/participant.set',
67
+ params: { participants: [] }
68
+ });
69
+ await SlatesProviderProtoHandlerManager.handleInput(manager, {
70
+ jsonrpc: '2.0',
71
+ method: 'slates/hub.capabilities.set',
72
+ params: { capabilities: { triggers: true } }
73
+ });
74
+
75
+ return {
76
+ contexts,
77
+ authenticate: (params: Record<string, unknown> = {}) =>
78
+ SlatesProviderProtoHandlerManager.handleInput(manager, {
79
+ jsonrpc: '2.0',
80
+ id: 'authenticate',
81
+ method: 'slates/trigger_group.webhook.delivery_authenticate',
82
+ params: {
83
+ triggerGroupId: 'configured',
84
+ url: 'https://hub.example/receive/config?attempt=1',
85
+ method: 'POST',
86
+ headers: { authorization: 'Bearer token', 'content-type': 'application/json' },
87
+ body: { encoding: 'base64', content: btoa('{"message":{}}') },
88
+ deliveryConfig: { audience: 'https://hub.example/receive/config', extra: [1, 2] },
89
+ ...params
90
+ }
91
+ } as any)
92
+ };
93
+ };
94
+
95
+ describe('slates/trigger_group.webhook.delivery_authenticate', () => {
96
+ it('hands the received request and the saved config to the handler without credentials', async () => {
97
+ let { authenticate, contexts } = await createManager(async () => ({
98
+ routingKeys: ['mailbox:user@example.com', 'mailbox:other@example.com']
99
+ }));
100
+
101
+ expect(await authenticate()).toEqual({
102
+ jsonrpc: '2.0',
103
+ id: 'authenticate',
104
+ result: {
105
+ routingKeys: ['mailbox:user@example.com', 'mailbox:other@example.com'],
106
+ rejected: null
107
+ }
108
+ });
109
+ let [received] = contexts;
110
+ expect(received).toMatchObject({
111
+ auth: {},
112
+ config: {},
113
+ deliveryConfig: { audience: 'https://hub.example/receive/config', extra: [1, 2] }
114
+ });
115
+ expect(received!.request.url).toBe('https://hub.example/receive/config?attempt=1');
116
+ expect(received!.request.method).toBe('POST');
117
+ expect(Object.fromEntries(received!.request.headers)).toEqual({
118
+ authorization: 'Bearer token',
119
+ 'content-type': 'application/json'
120
+ });
121
+ expect(await received!.request.text()).toBe('{"message":{}}');
122
+ });
123
+
124
+ it('reconstructs a request without a body', async () => {
125
+ let { authenticate, contexts } = await createManager(async () => ({ routingKeys: [] }));
126
+
127
+ await authenticate({ method: 'GET', headers: {}, body: null });
128
+ expect(contexts[0]!.request.method).toBe('GET');
129
+ expect(contexts[0]!.request.body).toBeNull();
130
+ });
131
+
132
+ it('acknowledges an authenticated request that addresses no registration', async () => {
133
+ let { authenticate } = await createManager(async () => ({ routingKeys: [] }));
134
+
135
+ expect(await authenticate()).toMatchObject({
136
+ result: { routingKeys: [], rejected: null }
137
+ });
138
+ });
139
+
140
+ it.each([
141
+ 401, 403, 404, 503
142
+ ])('passes a rejection with status %i through without routing keys', async status => {
143
+ let { authenticate } = await createManager(async () => ({ rejected: { status } }));
144
+
145
+ expect(await authenticate()).toMatchObject({
146
+ result: { routingKeys: [], rejected: { status } }
147
+ });
148
+ });
149
+
150
+ it.each([
151
+ ['a status outside 400-599', { rejected: { status: 200 } }],
152
+ ['a fractional status', { rejected: { status: 401.5 } }],
153
+ ['no routing keys and no rejection', {}],
154
+ ['an empty routing key', { routingKeys: [''] }],
155
+ ['a routing key over 512 characters', { routingKeys: ['x'.repeat(513)] }],
156
+ ['more than 16 routing keys', { routingKeys: Array(17).fill('key') }]
157
+ ])('rejects a handler result with %s', async (_, result) => {
158
+ let { authenticate } = await createManager(async () => result);
159
+
160
+ expect(await authenticate()).toMatchObject({
161
+ error: { message: 'Webhook delivery authentication returned an invalid result' }
162
+ });
163
+ });
164
+
165
+ it.each([
166
+ 'auto',
167
+ 'manual',
168
+ 'poll'
169
+ ])('rejects the %s group, which has no delivery config', async triggerGroupId => {
170
+ let { authenticate, contexts } = await createManager(async () => ({ routingKeys: [] }));
171
+
172
+ expect(await authenticate({ triggerGroupId })).toMatchObject({
173
+ error: {
174
+ message: `Trigger group does not support webhook delivery authentication: ${triggerGroupId}`
175
+ }
176
+ });
177
+ expect(contexts).toEqual([]);
178
+ });
179
+ });
@@ -0,0 +1,179 @@
1
+ import { SLATES_PROTOCOL_VERSION, SlatesProviderProtoHandlerManager } from '@slates/proto';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { z } from 'zod';
4
+ import { createProviderHandler } from './index';
5
+
6
+ let createManager = async () => {
7
+ let contexts: { operation: string; auth: unknown; config: unknown; input: unknown }[] = [];
8
+ let config = (finish = true) => ({
9
+ userConfigSchema: z.object({ secret: z.string() }),
10
+ fullConfigSchema: z.object({ secret: z.string(), endpoint: z.string() }),
11
+ setup: async (ctx: any) => {
12
+ contexts.push({
13
+ operation: 'setup',
14
+ auth: ctx.auth,
15
+ config: ctx.config,
16
+ input: ctx.input
17
+ });
18
+ return {
19
+ webhookSetupDocument: `Send events to ${ctx.input.webhookUrl}`,
20
+ partialWebhookRegistrationPayload: { endpoint: ctx.input.webhookUrl }
21
+ };
22
+ },
23
+ ...(finish
24
+ ? {
25
+ finish: async (ctx: any) => {
26
+ contexts.push({
27
+ operation: 'finish',
28
+ auth: ctx.auth,
29
+ config: ctx.config,
30
+ input: ctx.input
31
+ });
32
+ return {
33
+ webhookRegistrationPayload: {
34
+ ...ctx.input.partialWebhookRegistrationPayload,
35
+ ...ctx.input.userWebhookRegistrationPayload
36
+ }
37
+ };
38
+ }
39
+ }
40
+ : {})
41
+ });
42
+ let autoRegistration = {
43
+ webhookTargetList: async () => ({ targets: [], nextPageToken: null }),
44
+ webhookRegister: async () => ({
45
+ webhookRegistrationIdentifier: 'reg',
46
+ webhookRegistrationPayload: {}
47
+ }),
48
+ webhookUnregister: async () => {}
49
+ };
50
+ let group = (key: string, webhook: Record<string, unknown>) => ({
51
+ key,
52
+ name: key,
53
+ description: undefined,
54
+ metadata: {},
55
+ source: 'webhook',
56
+ webhook: { process: async () => ({ events: [] }), ...webhook }
57
+ });
58
+ let slate = {
59
+ spec: { key: 'test', name: 'Test' },
60
+ adapters: [],
61
+ actions: [],
62
+ triggerGroups: [
63
+ group('manual', { manualRegistration: config() }),
64
+ group('manual_without_finish', { manualRegistration: config(false) }),
65
+ group('configured', { autoRegistration, deliveryConfig: config() }),
66
+ group('auto', { autoRegistration }),
67
+ { key: 'poll', name: 'poll', source: 'polling', polling: { intervalSeconds: 600 } }
68
+ ]
69
+ };
70
+ let manager = await createProviderHandler(slate as any, []).run();
71
+
72
+ await SlatesProviderProtoHandlerManager.handleInput(manager, {
73
+ jsonrpc: '2.0',
74
+ method: 'slates/hello',
75
+ params: { protocol: SLATES_PROTOCOL_VERSION }
76
+ });
77
+ await SlatesProviderProtoHandlerManager.handleInput(manager, {
78
+ jsonrpc: '2.0',
79
+ method: 'slates/participant.set',
80
+ params: { participants: [] }
81
+ });
82
+ await SlatesProviderProtoHandlerManager.handleInput(manager, {
83
+ jsonrpc: '2.0',
84
+ method: 'slates/hub.capabilities.set',
85
+ params: { capabilities: { triggers: true } }
86
+ });
87
+
88
+ let request = (method: string, params: Record<string, unknown>) =>
89
+ SlatesProviderProtoHandlerManager.handleInput(manager, {
90
+ jsonrpc: '2.0',
91
+ id: method,
92
+ method,
93
+ params
94
+ } as any);
95
+
96
+ return {
97
+ contexts,
98
+ setup: (triggerGroupId: string) =>
99
+ request('slates/trigger_group.webhook.manual_setup', {
100
+ triggerGroupId,
101
+ webhookUrl: 'https://receive.example/key'
102
+ }),
103
+ finish: (triggerGroupId: string) =>
104
+ request('slates/trigger_group.webhook.manual_finish', {
105
+ triggerGroupId,
106
+ webhookUrl: 'https://receive.example/key',
107
+ partialWebhookRegistrationPayload: { endpoint: 'https://receive.example/key' },
108
+ userWebhookRegistrationPayload: { secret: 'shared' }
109
+ })
110
+ };
111
+ };
112
+
113
+ describe('slates/trigger_group.webhook.manual_setup and manual_finish', () => {
114
+ it.each([
115
+ 'manual',
116
+ 'configured'
117
+ ])('serve the %s group with unchanged shapes and an empty context', async triggerGroupId => {
118
+ let { setup, finish, contexts } = await createManager();
119
+
120
+ expect(await setup(triggerGroupId)).toMatchObject({
121
+ result: {
122
+ webhookSetupDocument: 'Send events to https://receive.example/key',
123
+ partialWebhookRegistrationPayload: { endpoint: 'https://receive.example/key' }
124
+ }
125
+ });
126
+ expect(await finish(triggerGroupId)).toMatchObject({
127
+ result: {
128
+ webhookRegistrationPayload: {
129
+ endpoint: 'https://receive.example/key',
130
+ secret: 'shared'
131
+ }
132
+ }
133
+ });
134
+ expect(contexts).toEqual([
135
+ {
136
+ operation: 'setup',
137
+ auth: {},
138
+ config: {},
139
+ input: { webhookUrl: 'https://receive.example/key' }
140
+ },
141
+ {
142
+ operation: 'finish',
143
+ auth: {},
144
+ config: {},
145
+ input: {
146
+ webhookUrl: 'https://receive.example/key',
147
+ partialWebhookRegistrationPayload: { endpoint: 'https://receive.example/key' },
148
+ userWebhookRegistrationPayload: { secret: 'shared' }
149
+ }
150
+ }
151
+ ]);
152
+ });
153
+
154
+ it.each([
155
+ 'auto',
156
+ 'poll'
157
+ ])('reject the %s group, which has no setup to complete', async triggerGroupId => {
158
+ let { setup, finish, contexts } = await createManager();
159
+
160
+ for (let response of [await setup(triggerGroupId), await finish(triggerGroupId)]) {
161
+ expect(response).toMatchObject({
162
+ error: {
163
+ message: `Trigger group does not support manual webhook registration: ${triggerGroupId}`
164
+ }
165
+ });
166
+ }
167
+ expect(contexts).toEqual([]);
168
+ });
169
+
170
+ it('reject finish for a manual group that does not implement it', async () => {
171
+ let { finish } = await createManager();
172
+
173
+ expect(await finish('manual_without_finish')).toMatchObject({
174
+ error: {
175
+ message: 'Trigger group does not support manual webhook finish: manual_without_finish'
176
+ }
177
+ });
178
+ });
179
+ });