@slates/provider 1.0.0-rc.17 → 1.0.0-rc.19
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/dist/index.cjs +569 -55
- package/dist/index.d.cts +218 -48
- package/dist/index.d.ts +218 -48
- package/dist/index.module.js +554 -55
- package/package.json +3 -2
- package/src/action/action.ts +19 -1
- package/src/action/attachment.ts +55 -9
- package/src/action/builder.test.ts +136 -0
- package/src/action/builder.ts +68 -11
- package/src/action/directUpload.ts +59 -0
- package/src/action/getFileUrlTool.ts +41 -0
- package/src/action/index.ts +3 -1
- package/src/action/publicTool.ts +36 -0
- package/src/action/tool.ts +15 -3
- package/src/adapter/adapter.ts +38 -0
- package/src/adapter/index.test.ts +322 -0
- package/src/adapter/index.ts +2 -0
- package/src/adapter/spec.ts +154 -0
- package/src/auth/index.ts +1 -0
- package/src/auth/redact.test.ts +103 -0
- package/src/auth/redact.ts +111 -0
- package/src/auth/types.ts +0 -1
- package/src/axios/trace.ts +72 -38
- package/src/context/context.ts +222 -17
- package/src/context/hook.ts +5 -10
- package/src/index.ts +2 -0
- package/src/specification/slate.ts +38 -2
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { SlateTool } from '../action/tool';
|
|
4
|
+
import { SlateAuth } from '../auth';
|
|
5
|
+
import { SlateConfig } from '../config';
|
|
6
|
+
import { Slate } from '../specification/slate';
|
|
7
|
+
import { SlateSpecification } from '../specification/specification';
|
|
8
|
+
import { SlateAdapterSpec } from './spec';
|
|
9
|
+
|
|
10
|
+
let createTestSpec = () => {
|
|
11
|
+
let config = SlateConfig.create(z.object({}));
|
|
12
|
+
let auth = SlateAuth.create<{}>().output(z.object({}));
|
|
13
|
+
|
|
14
|
+
return SlateSpecification.create({
|
|
15
|
+
key: 'demo',
|
|
16
|
+
name: 'Demo',
|
|
17
|
+
config,
|
|
18
|
+
auth
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('SlateAdapterSpec', () => {
|
|
23
|
+
it('creates adapter tools and triggers linked to the adapter, then registers them', () => {
|
|
24
|
+
let spec = createTestSpec();
|
|
25
|
+
let gmailAdapterSpec = SlateAdapterSpec.create({
|
|
26
|
+
id: 'gmail',
|
|
27
|
+
name: 'Gmail',
|
|
28
|
+
capabilities: [{ id: 'send_email', value: true }]
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let sendEmailTool = gmailAdapterSpec
|
|
32
|
+
.tool(spec, {
|
|
33
|
+
key: 'send_email',
|
|
34
|
+
name: 'Send Email'
|
|
35
|
+
})
|
|
36
|
+
.input(z.object({ to: z.string() }))
|
|
37
|
+
.output(z.object({ id: z.string() }))
|
|
38
|
+
.handleInvocation(async () => ({
|
|
39
|
+
output: { id: 'msg_1' },
|
|
40
|
+
message: 'sent'
|
|
41
|
+
}))
|
|
42
|
+
.build();
|
|
43
|
+
|
|
44
|
+
let emailReceivedTrigger = gmailAdapterSpec
|
|
45
|
+
.trigger(spec, {
|
|
46
|
+
key: 'email_received',
|
|
47
|
+
name: 'Email Received'
|
|
48
|
+
})
|
|
49
|
+
.input(z.object({ messageId: z.string() }))
|
|
50
|
+
.output(z.object({ type: z.literal('email.received') }))
|
|
51
|
+
.polling({
|
|
52
|
+
handleEvent: async ctx => ({
|
|
53
|
+
type: 'email.received',
|
|
54
|
+
id: ctx.input.messageId,
|
|
55
|
+
output: { type: 'email.received' as const }
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
.build();
|
|
59
|
+
|
|
60
|
+
expect(sendEmailTool.adapter).toBe('gmail');
|
|
61
|
+
expect(emailReceivedTrigger.adapter).toBe('gmail');
|
|
62
|
+
|
|
63
|
+
let gmailAdapter = gmailAdapterSpec.register({
|
|
64
|
+
tools: [sendEmailTool],
|
|
65
|
+
triggers: [emailReceivedTrigger]
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(gmailAdapter.id).toBe('gmail');
|
|
69
|
+
expect(gmailAdapter.name).toBe('Gmail');
|
|
70
|
+
expect(gmailAdapter.capabilities).toEqual([{ id: 'send_email', value: true }]);
|
|
71
|
+
expect(gmailAdapter.tools).toEqual([sendEmailTool]);
|
|
72
|
+
expect(gmailAdapter.triggers).toEqual([emailReceivedTrigger]);
|
|
73
|
+
|
|
74
|
+
let providerTool = SlateTool.create(spec, {
|
|
75
|
+
key: 'list_labels',
|
|
76
|
+
name: 'List Labels'
|
|
77
|
+
})
|
|
78
|
+
.input(z.object({}))
|
|
79
|
+
.output(z.object({ labels: z.array(z.string()) }))
|
|
80
|
+
.handleInvocation(async () => ({
|
|
81
|
+
output: { labels: [] },
|
|
82
|
+
message: 'ok'
|
|
83
|
+
}))
|
|
84
|
+
.build();
|
|
85
|
+
|
|
86
|
+
let slate = Slate.create({
|
|
87
|
+
spec,
|
|
88
|
+
tools: [providerTool, sendEmailTool],
|
|
89
|
+
triggers: [emailReceivedTrigger],
|
|
90
|
+
adapters: [gmailAdapter]
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(slate.actions.map(action => action.key)).toEqual([
|
|
94
|
+
'email_received',
|
|
95
|
+
'list_labels',
|
|
96
|
+
'send_email'
|
|
97
|
+
]);
|
|
98
|
+
expect(slate.adapters).toEqual([gmailAdapter]);
|
|
99
|
+
expect(slate.actions.filter(action => !action.adapter).map(action => action.key)).toEqual([
|
|
100
|
+
'list_labels'
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('includes adapter actions in the slate even when they are only passed via the adapter', () => {
|
|
105
|
+
let spec = createTestSpec();
|
|
106
|
+
let gmailAdapterSpec = SlateAdapterSpec.create({
|
|
107
|
+
id: 'gmail',
|
|
108
|
+
name: 'Gmail'
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
let sendEmailTool = gmailAdapterSpec
|
|
112
|
+
.tool(spec, {
|
|
113
|
+
key: 'send_email',
|
|
114
|
+
name: 'Send Email'
|
|
115
|
+
})
|
|
116
|
+
.input(z.object({}))
|
|
117
|
+
.output(z.object({}))
|
|
118
|
+
.handleInvocation(async () => ({
|
|
119
|
+
output: {},
|
|
120
|
+
message: 'ok'
|
|
121
|
+
}))
|
|
122
|
+
.build();
|
|
123
|
+
|
|
124
|
+
let gmailAdapter = gmailAdapterSpec.register({
|
|
125
|
+
tools: [sendEmailTool],
|
|
126
|
+
triggers: []
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
let slate = Slate.create({
|
|
130
|
+
spec,
|
|
131
|
+
tools: [],
|
|
132
|
+
triggers: [],
|
|
133
|
+
adapters: [gmailAdapter]
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(slate.actions).toEqual([sendEmailTool]);
|
|
137
|
+
expect(slate.actions[0]!.adapter).toBe('gmail');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('rejects tools created for a different adapter', () => {
|
|
141
|
+
let spec = createTestSpec();
|
|
142
|
+
let gmailAdapterSpec = SlateAdapterSpec.create({
|
|
143
|
+
id: 'gmail',
|
|
144
|
+
name: 'Gmail'
|
|
145
|
+
});
|
|
146
|
+
let outlookAdapterSpec = SlateAdapterSpec.create({
|
|
147
|
+
id: 'outlook',
|
|
148
|
+
name: 'Outlook'
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
let outlookTool = outlookAdapterSpec
|
|
152
|
+
.tool(spec, {
|
|
153
|
+
key: 'send_email',
|
|
154
|
+
name: 'Send Email'
|
|
155
|
+
})
|
|
156
|
+
.input(z.object({}))
|
|
157
|
+
.output(z.object({}))
|
|
158
|
+
.handleInvocation(async () => ({
|
|
159
|
+
output: {},
|
|
160
|
+
message: 'ok'
|
|
161
|
+
}))
|
|
162
|
+
.build();
|
|
163
|
+
|
|
164
|
+
expect(() =>
|
|
165
|
+
gmailAdapterSpec.register({
|
|
166
|
+
tools: [outlookTool],
|
|
167
|
+
triggers: []
|
|
168
|
+
})
|
|
169
|
+
).toThrow('Action "send_email" is linked to adapter "outlook", not "gmail"');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('rejects provider tools that were not created through the adapter helpers', () => {
|
|
173
|
+
let spec = createTestSpec();
|
|
174
|
+
let gmailAdapterSpec = SlateAdapterSpec.create({
|
|
175
|
+
id: 'gmail',
|
|
176
|
+
name: 'Gmail'
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
let providerTool = SlateTool.create(spec, {
|
|
180
|
+
key: 'send_email',
|
|
181
|
+
name: 'Send Email'
|
|
182
|
+
})
|
|
183
|
+
.input(z.object({}))
|
|
184
|
+
.output(z.object({}))
|
|
185
|
+
.handleInvocation(async () => ({
|
|
186
|
+
output: {},
|
|
187
|
+
message: 'ok'
|
|
188
|
+
}))
|
|
189
|
+
.build();
|
|
190
|
+
|
|
191
|
+
expect(() =>
|
|
192
|
+
gmailAdapterSpec.register({
|
|
193
|
+
tools: [providerTool],
|
|
194
|
+
triggers: []
|
|
195
|
+
})
|
|
196
|
+
).toThrow(
|
|
197
|
+
'Action "send_email" must be created with this adapter\'s tool(), publicTool(), or trigger() helpers'
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('rejects duplicate adapter action keys and duplicate adapter ids', () => {
|
|
202
|
+
let spec = createTestSpec();
|
|
203
|
+
let gmailAdapterSpec = SlateAdapterSpec.create({
|
|
204
|
+
id: 'gmail',
|
|
205
|
+
name: 'Gmail'
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
let sendEmailTool = gmailAdapterSpec
|
|
209
|
+
.tool(spec, {
|
|
210
|
+
key: 'send_email',
|
|
211
|
+
name: 'Send Email'
|
|
212
|
+
})
|
|
213
|
+
.input(z.object({}))
|
|
214
|
+
.output(z.object({}))
|
|
215
|
+
.handleInvocation(async () => ({
|
|
216
|
+
output: {},
|
|
217
|
+
message: 'ok'
|
|
218
|
+
}))
|
|
219
|
+
.build();
|
|
220
|
+
|
|
221
|
+
let sendEmailTrigger = gmailAdapterSpec
|
|
222
|
+
.trigger(spec, {
|
|
223
|
+
key: 'send_email',
|
|
224
|
+
name: 'Send Email Trigger'
|
|
225
|
+
})
|
|
226
|
+
.input(z.object({}))
|
|
227
|
+
.output(z.object({ type: z.string() }))
|
|
228
|
+
.polling({
|
|
229
|
+
handleEvent: async () => ({
|
|
230
|
+
type: 'email.sent',
|
|
231
|
+
id: '1',
|
|
232
|
+
output: { type: 'email.sent' }
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
.build();
|
|
236
|
+
|
|
237
|
+
expect(() =>
|
|
238
|
+
gmailAdapterSpec.register({
|
|
239
|
+
tools: [sendEmailTool],
|
|
240
|
+
triggers: [sendEmailTrigger]
|
|
241
|
+
})
|
|
242
|
+
).toThrow('Adapter "gmail" already has an action with key "send_email"');
|
|
243
|
+
|
|
244
|
+
let adapter = gmailAdapterSpec.register({
|
|
245
|
+
tools: [sendEmailTool],
|
|
246
|
+
triggers: []
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(() =>
|
|
250
|
+
Slate.create({
|
|
251
|
+
spec,
|
|
252
|
+
tools: [],
|
|
253
|
+
triggers: [],
|
|
254
|
+
adapters: [adapter, adapter]
|
|
255
|
+
})
|
|
256
|
+
).toThrow('Adapter "gmail" is registered more than once');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('rejects empty adapter ids and duplicate capability ids', () => {
|
|
260
|
+
expect(() =>
|
|
261
|
+
SlateAdapterSpec.create({
|
|
262
|
+
id: ' ',
|
|
263
|
+
name: 'Gmail'
|
|
264
|
+
})
|
|
265
|
+
).toThrow('Adapter id must be a non-empty string');
|
|
266
|
+
|
|
267
|
+
expect(() =>
|
|
268
|
+
SlateAdapterSpec.create({
|
|
269
|
+
id: 'gmail',
|
|
270
|
+
name: 'Gmail',
|
|
271
|
+
capabilities: [
|
|
272
|
+
{ id: 'send_email', value: true },
|
|
273
|
+
{ id: 'send_email', value: false }
|
|
274
|
+
]
|
|
275
|
+
})
|
|
276
|
+
).toThrow('Adapter capability "send_email" is defined more than once');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('creates public adapter tools that remain linked and public', () => {
|
|
280
|
+
let spec = createTestSpec();
|
|
281
|
+
let gmailAdapterSpec = SlateAdapterSpec.create({
|
|
282
|
+
id: 'gmail',
|
|
283
|
+
name: 'Gmail'
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
let setupTool = gmailAdapterSpec
|
|
287
|
+
.publicTool(spec, {
|
|
288
|
+
key: 'setup',
|
|
289
|
+
name: 'Setup'
|
|
290
|
+
})
|
|
291
|
+
.input(z.object({}))
|
|
292
|
+
.output(z.object({ ok: z.boolean() }))
|
|
293
|
+
.handleInvocation(async ctx => {
|
|
294
|
+
expect(ctx).not.toHaveProperty('config');
|
|
295
|
+
expect(ctx).not.toHaveProperty('auth');
|
|
296
|
+
return {
|
|
297
|
+
output: { ok: true },
|
|
298
|
+
message: 'ok'
|
|
299
|
+
};
|
|
300
|
+
})
|
|
301
|
+
.build();
|
|
302
|
+
|
|
303
|
+
expect(setupTool.adapter).toBe('gmail');
|
|
304
|
+
expect(setupTool.isPublic).toBe(true);
|
|
305
|
+
|
|
306
|
+
let gmailAdapter = gmailAdapterSpec.register({
|
|
307
|
+
tools: [setupTool],
|
|
308
|
+
triggers: []
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
let slate = Slate.create({
|
|
312
|
+
spec,
|
|
313
|
+
tools: [setupTool],
|
|
314
|
+
triggers: [],
|
|
315
|
+
adapters: [gmailAdapter]
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
expect(slate.actions.map(action => action.key)).toEqual(['setup']);
|
|
319
|
+
expect(slate.actions[0]!.isPublic).toBe(true);
|
|
320
|
+
expect(slate.actions[0]!.adapter).toBe('gmail');
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type SlateActionParameters,
|
|
3
|
+
SlatePublicTool,
|
|
4
|
+
SlateTool,
|
|
5
|
+
SlateTrigger
|
|
6
|
+
} from '../action';
|
|
7
|
+
import { SlateDeclarationError } from '../error';
|
|
8
|
+
import type { SlateSpecification } from '../specification/specification';
|
|
9
|
+
import { SlateAdapter } from './adapter';
|
|
10
|
+
|
|
11
|
+
export interface SlateAdapterCapability {
|
|
12
|
+
id: string;
|
|
13
|
+
value: any;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SlateAdapterSpecParameters {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
capabilities?: SlateAdapterCapability[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class SlateAdapterSpec {
|
|
23
|
+
private constructor(
|
|
24
|
+
private readonly _params: {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
capabilities: SlateAdapterCapability[];
|
|
28
|
+
}
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
static create(params: SlateAdapterSpecParameters): SlateAdapterSpec {
|
|
32
|
+
let id = params.id?.trim();
|
|
33
|
+
let name = params.name?.trim();
|
|
34
|
+
|
|
35
|
+
if (!id) {
|
|
36
|
+
throw new SlateDeclarationError('Adapter id must be a non-empty string');
|
|
37
|
+
}
|
|
38
|
+
if (!name) {
|
|
39
|
+
throw new SlateDeclarationError('Adapter name must be a non-empty string');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return new SlateAdapterSpec({
|
|
43
|
+
id,
|
|
44
|
+
name,
|
|
45
|
+
capabilities: validateCapabilities(params.capabilities ?? [])
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get id() {
|
|
50
|
+
return this._params.id;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get name() {
|
|
54
|
+
return this._params.name;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get capabilities() {
|
|
58
|
+
return this._params.capabilities;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get parameters() {
|
|
62
|
+
return this._params;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
tool<ConfigType extends {}, AuthType extends {}>(
|
|
66
|
+
spec: SlateSpecification<ConfigType, AuthType>,
|
|
67
|
+
params: SlateActionParameters
|
|
68
|
+
) {
|
|
69
|
+
return SlateTool.create(spec, this.linkAction(params));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
publicTool<ConfigType extends {}, AuthType extends {}>(
|
|
73
|
+
spec: SlateSpecification<ConfigType, AuthType>,
|
|
74
|
+
params: SlateActionParameters
|
|
75
|
+
) {
|
|
76
|
+
return SlatePublicTool.create(spec, this.linkAction(params));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
trigger<ConfigType extends {}, AuthType extends {}>(
|
|
80
|
+
spec: SlateSpecification<ConfigType, AuthType>,
|
|
81
|
+
params: SlateActionParameters
|
|
82
|
+
) {
|
|
83
|
+
return SlateTrigger.create(spec, this.linkAction(params));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
register<ConfigType extends {}, AuthType extends {}>(params: {
|
|
87
|
+
tools: SlateTool<ConfigType, AuthType, any, any>[];
|
|
88
|
+
triggers: SlateTrigger<ConfigType, AuthType, any, any>[];
|
|
89
|
+
}): SlateAdapter<ConfigType, AuthType> {
|
|
90
|
+
let actions = [...params.tools, ...params.triggers];
|
|
91
|
+
let seenKeys = new Set<string>();
|
|
92
|
+
|
|
93
|
+
for (let action of actions) {
|
|
94
|
+
if (action.adapter && action.adapter !== this.id) {
|
|
95
|
+
throw new SlateDeclarationError(
|
|
96
|
+
`Action "${action.key}" is linked to adapter "${action.adapter}", not "${this.id}"`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!action.adapter) {
|
|
101
|
+
throw new SlateDeclarationError(
|
|
102
|
+
`Action "${action.key}" must be created with this adapter's tool(), publicTool(), or trigger() helpers`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (seenKeys.has(action.key)) {
|
|
107
|
+
throw new SlateDeclarationError(
|
|
108
|
+
`Adapter "${this.id}" already has an action with key "${action.key}"`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
seenKeys.add(action.key);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return new SlateAdapter(this, params.tools, params.triggers);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private linkAction(params: SlateActionParameters): SlateActionParameters {
|
|
119
|
+
if (params.adapter != null && params.adapter !== this.id) {
|
|
120
|
+
throw new SlateDeclarationError(
|
|
121
|
+
`Action "${params.key}" cannot be linked to adapter "${this.id}" because it is already linked to "${params.adapter}"`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
...params,
|
|
127
|
+
adapter: this.id
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export let adapterSpec = (params: SlateAdapterSpecParameters) =>
|
|
133
|
+
SlateAdapterSpec.create(params);
|
|
134
|
+
|
|
135
|
+
let validateCapabilities = (capabilities: SlateAdapterCapability[]) => {
|
|
136
|
+
let seenIds = new Set<string>();
|
|
137
|
+
|
|
138
|
+
return capabilities.map(capability => {
|
|
139
|
+
let id = capability.id?.trim();
|
|
140
|
+
if (!id) {
|
|
141
|
+
throw new SlateDeclarationError('Adapter capability ids must be non-empty strings');
|
|
142
|
+
}
|
|
143
|
+
if (seenIds.has(id)) {
|
|
144
|
+
throw new SlateDeclarationError(`Adapter capability "${id}" is defined more than once`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
seenIds.add(id);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
id,
|
|
151
|
+
value: capability.value
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
};
|
package/src/auth/index.ts
CHANGED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { SlateAttachment } from '../action/attachment';
|
|
3
|
+
import { AuthConfigSecretRedactor, redactUrlAttachmentSecrets } from './redact';
|
|
4
|
+
|
|
5
|
+
describe('AuthConfigSecretRedactor', () => {
|
|
6
|
+
let authConfig = { token: '123SECRET', nested: { token2: 'also_secret' } };
|
|
7
|
+
|
|
8
|
+
it('redacts values that match a secret to placeholders', () => {
|
|
9
|
+
let redactor = new AuthConfigSecretRedactor(authConfig);
|
|
10
|
+
|
|
11
|
+
expect(
|
|
12
|
+
redactor.redact({
|
|
13
|
+
headers: { Authorization: 'Bearer 123SECRET' },
|
|
14
|
+
query: { key: 'also_secret' }
|
|
15
|
+
})
|
|
16
|
+
).toEqual({
|
|
17
|
+
headers: { Authorization: 'Bearer 123SECRET' }, // whole-value match only, not substring
|
|
18
|
+
query: { key: '$$MT$secret$authConfig$nested.token2' }
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('redacts a bare string value', () => {
|
|
23
|
+
let redactor = new AuthConfigSecretRedactor(authConfig);
|
|
24
|
+
expect(redactor.redact('123SECRET')).toBe('$$MT$secret$authConfig$token');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('leaves non-secret strings untouched', () => {
|
|
28
|
+
let redactor = new AuthConfigSecretRedactor(authConfig);
|
|
29
|
+
expect(redactor.redact('https://example.com/file.pdf')).toBe(
|
|
30
|
+
'https://example.com/file.pdf'
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('recurses through arrays and nested objects', () => {
|
|
35
|
+
let redactor = new AuthConfigSecretRedactor(authConfig);
|
|
36
|
+
expect(redactor.redact({ list: ['also_secret', 'plain'] })).toEqual({
|
|
37
|
+
list: ['$$MT$secret$authConfig$nested.token2', 'plain']
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('never registers an empty-string secret', () => {
|
|
42
|
+
let redactor = new AuthConfigSecretRedactor({ token: '' });
|
|
43
|
+
expect(redactor.redact({ a: '', b: '' })).toEqual({ a: '', b: '' });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('is a no-op when there is no auth config', () => {
|
|
47
|
+
let redactor = new AuthConfigSecretRedactor(undefined);
|
|
48
|
+
expect(redactor.redact({ a: 'b' })).toEqual({ a: 'b' });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('redactUrlAttachmentSecrets', () => {
|
|
53
|
+
let authConfig = { token: '123SECRET', nested: { token2: 'also_secret' } };
|
|
54
|
+
|
|
55
|
+
let urlAttachment = (
|
|
56
|
+
overrides: Partial<Extract<SlateAttachment['content'], { type: 'url' }>> = {}
|
|
57
|
+
): SlateAttachment => ({
|
|
58
|
+
mimeType: 'application/pdf',
|
|
59
|
+
content: { type: 'url', url: 'https://example.com/file.pdf', ...overrides }
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('redacts headers and query on url attachments', () => {
|
|
63
|
+
let attachments: SlateAttachment[] = [
|
|
64
|
+
urlAttachment({
|
|
65
|
+
headers: { Authorization: 'Bearer 123SECRET' },
|
|
66
|
+
query: { key: 'also_secret' }
|
|
67
|
+
})
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
expect(redactUrlAttachmentSecrets(attachments, authConfig)).toEqual([
|
|
71
|
+
urlAttachment({
|
|
72
|
+
headers: { Authorization: 'Bearer 123SECRET' },
|
|
73
|
+
query: { key: '$$MT$secret$authConfig$nested.token2' }
|
|
74
|
+
})
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('leaves url attachments with no headers/query untouched', () => {
|
|
79
|
+
let attachments = [urlAttachment()];
|
|
80
|
+
expect(redactUrlAttachmentSecrets(attachments, authConfig)).toEqual(attachments);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('leaves content and upload_reference attachments untouched', () => {
|
|
84
|
+
let attachments: SlateAttachment[] = [
|
|
85
|
+
{
|
|
86
|
+
mimeType: 'text/plain',
|
|
87
|
+
content: { type: 'content', encoding: 'utf-8', content: 'hi' }
|
|
88
|
+
},
|
|
89
|
+
{ content: { type: 'upload_reference', referenceId: 'ref_1' } }
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
expect(redactUrlAttachmentSecrets(attachments, authConfig)).toEqual(attachments);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('is a no-op when there is no auth config', () => {
|
|
96
|
+
let attachments = [urlAttachment({ headers: { Authorization: 'Bearer 123SECRET' } })];
|
|
97
|
+
expect(redactUrlAttachmentSecrets(attachments, undefined)).toBe(attachments);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('passes an undefined attachment list through unchanged', () => {
|
|
101
|
+
expect(redactUrlAttachmentSecrets(undefined, authConfig)).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { SlateAttachment } from '../action/attachment';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Placeholder prefix used to mark a redacted auth-config secret. Must match the hub's
|
|
5
|
+
* AuthConfigSecretSerializer (oss/src/slates/apps/hub/src/lib/secretSerializer.ts) exactly --
|
|
6
|
+
* the hub resolves these placeholders back to live secret values when it proxies an attachment.
|
|
7
|
+
*/
|
|
8
|
+
export let AUTH_CONFIG_SECRET_PLACEHOLDER_PREFIX = '$$MT$secret$authConfig$';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Walks an auth config object and replaces any string value that exactly matches one of its
|
|
12
|
+
* leaf values with a `$$MT$secret$authConfig$<path>` placeholder. Literal, whole-value
|
|
13
|
+
* substitution only -- not a substring scan, and not a heuristic key-name/value-shape guess
|
|
14
|
+
* like the http trace redactor in '../axios/trace'.
|
|
15
|
+
*/
|
|
16
|
+
export class AuthConfigSecretRedactor {
|
|
17
|
+
private readonly secretToPlaceholder = new Map<string, string>();
|
|
18
|
+
|
|
19
|
+
public constructor(authConfig: Record<string, unknown> | undefined) {
|
|
20
|
+
if (authConfig) this.buildSecretMap(authConfig);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public redact<T>(value: T): T {
|
|
24
|
+
return this.transform(value, stringValue => {
|
|
25
|
+
return this.secretToPlaceholder.get(stringValue) ?? stringValue;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private buildSecretMap(value: unknown, path: string[] = []): void {
|
|
30
|
+
if (typeof value === 'string') {
|
|
31
|
+
// Never register "" -- it would replace every empty header/query value with a placeholder.
|
|
32
|
+
if (value.length === 0) return;
|
|
33
|
+
|
|
34
|
+
this.secretToPlaceholder.set(
|
|
35
|
+
value,
|
|
36
|
+
`${AUTH_CONFIG_SECRET_PLACEHOLDER_PREFIX}${path.join('.')}`
|
|
37
|
+
);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (Array.isArray(value)) {
|
|
42
|
+
value.forEach((item, index) => {
|
|
43
|
+
this.buildSecretMap(item, [...path, String(index)]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (this.isPlainObject(value)) {
|
|
50
|
+
Object.entries(value).forEach(([key, nestedValue]) => {
|
|
51
|
+
this.buildSecretMap(nestedValue, [...path, key]);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private transform<T>(value: T, transformString: (value: string) => string): T {
|
|
57
|
+
if (typeof value === 'string') {
|
|
58
|
+
return transformString(value) as T;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (Array.isArray(value)) {
|
|
62
|
+
return value.map(item => this.transform(item, transformString)) as T;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (this.isPlainObject(value)) {
|
|
66
|
+
let result: Record<string, unknown> = {};
|
|
67
|
+
|
|
68
|
+
Object.entries(value).forEach(([key, nestedValue]) => {
|
|
69
|
+
result[key] = this.transform(nestedValue, transformString);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return result as T;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
79
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Applied to every finalized attachment list right before it's sent over
|
|
85
|
+
* `slates/action.tool.invoke` (see @slates/provider-handler). Only `type: 'url'` attachments
|
|
86
|
+
* carry headers/query -- content/upload_reference attachments pass through untouched.
|
|
87
|
+
*/
|
|
88
|
+
export let redactUrlAttachmentSecrets = (
|
|
89
|
+
attachments: SlateAttachment[] | undefined,
|
|
90
|
+
authConfig: Record<string, unknown> | undefined
|
|
91
|
+
): SlateAttachment[] | undefined => {
|
|
92
|
+
if (!authConfig || !attachments) return attachments;
|
|
93
|
+
|
|
94
|
+
let redactor = new AuthConfigSecretRedactor(authConfig);
|
|
95
|
+
|
|
96
|
+
return attachments.map(attachment => {
|
|
97
|
+
if (attachment.content.type !== 'url') return attachment;
|
|
98
|
+
if (!attachment.content.headers && !attachment.content.query) return attachment;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
...attachment,
|
|
102
|
+
content: {
|
|
103
|
+
...attachment.content,
|
|
104
|
+
headers: attachment.content.headers
|
|
105
|
+
? redactor.redact(attachment.content.headers)
|
|
106
|
+
: undefined,
|
|
107
|
+
query: attachment.content.query ? redactor.redact(attachment.content.query) : undefined
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
};
|