@slates/client 1.0.0-rc.11

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.
@@ -0,0 +1,845 @@
1
+ import {
2
+ axios,
3
+ Slate,
4
+ SlateAuth,
5
+ SlateConfig,
6
+ type SlateLogEntry,
7
+ SlateSpecification,
8
+ SlateTool,
9
+ SlateTrigger
10
+ } from '@slates/provider';
11
+ import { rm } from 'fs/promises';
12
+ import { createServer } from 'http';
13
+ import type { AddressInfo } from 'net';
14
+ import { afterEach, describe, expect, it } from 'vitest';
15
+ import { z } from 'zod';
16
+ import { createLocalSlateTransport, createSlatesClient, SlateProtocolError } from './index';
17
+
18
+ let tempDirs: string[] = [];
19
+
20
+ let waitForLogs = async () => {
21
+ await new Promise(resolve => setTimeout(resolve, 25));
22
+ };
23
+
24
+ afterEach(async () => {
25
+ await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })));
26
+ });
27
+
28
+ let createDemoSlate = () => {
29
+ let demoConfig = SlateConfig.create(
30
+ z.object({
31
+ prefix: z.string()
32
+ })
33
+ ).getDefaultConfig(() => ({
34
+ prefix: 'Hello'
35
+ }));
36
+
37
+ let demoAuth = SlateAuth.create<{ token: string }>()
38
+ .output(
39
+ z.object({
40
+ token: z.string()
41
+ })
42
+ )
43
+ .addTokenAuth({
44
+ type: 'auth.token',
45
+ key: 'token_auth',
46
+ name: 'Token Auth',
47
+ inputSchema: z.object({
48
+ token: z.string()
49
+ }),
50
+ getDefaultInput: async () => ({
51
+ token: 'default-token'
52
+ }),
53
+ onInputChanged: async (ctx: { newInput: { token: string } }) => ({
54
+ input: {
55
+ token: ctx.newInput.token.trim()
56
+ }
57
+ }),
58
+ getOutput: async (ctx: { input: { token: string } }) => ({
59
+ output: {
60
+ token: ctx.input.token
61
+ }
62
+ }),
63
+ getProfile: async (ctx: { output: { token: string } }) => ({
64
+ profile: {
65
+ tokenPreview: ctx.output.token.slice(0, 3)
66
+ }
67
+ })
68
+ });
69
+
70
+ let spec = SlateSpecification.create({
71
+ key: 'demo-slate',
72
+ name: 'Demo Slate',
73
+ description: 'A tiny test slate',
74
+ config: demoConfig,
75
+ auth: demoAuth
76
+ });
77
+
78
+ let echoTool = SlateTool.create(spec, {
79
+ key: 'echo',
80
+ name: 'Echo'
81
+ })
82
+ .input(
83
+ z.object({
84
+ name: z.string()
85
+ })
86
+ )
87
+ .output(
88
+ z.object({
89
+ greeting: z.string(),
90
+ token: z.string()
91
+ })
92
+ )
93
+ .scopes({
94
+ AND: [
95
+ {
96
+ OR: ['scope:echo', 'scope:echo:admin']
97
+ }
98
+ ]
99
+ })
100
+ .handleInvocation(async ctx => ({
101
+ output: {
102
+ greeting: `${ctx.config.prefix} ${ctx.input.name}`,
103
+ token: ctx.auth.token
104
+ },
105
+ message: 'done'
106
+ }))
107
+ .build();
108
+
109
+ return Slate.create({
110
+ spec,
111
+ tools: [echoTool],
112
+ triggers: []
113
+ });
114
+ };
115
+
116
+ let createTraceSlate = (baseUrl: string) => {
117
+ let config = SlateConfig.create(z.object({}));
118
+ let auth = SlateAuth.create<{}>().output(z.object({}));
119
+
120
+ let spec = SlateSpecification.create({
121
+ key: 'trace-slate',
122
+ name: 'Trace Slate',
123
+ description: 'A slate that traces HTTP requests',
124
+ config,
125
+ auth
126
+ });
127
+
128
+ let traceTool = SlateTool.create(spec, {
129
+ key: 'trace_http',
130
+ name: 'Trace HTTP'
131
+ })
132
+ .input(z.object({}))
133
+ .output(
134
+ z.object({
135
+ ok: z.boolean(),
136
+ status: z.number(),
137
+ traceCount: z.number()
138
+ })
139
+ )
140
+ .handleInvocation(async ctx => {
141
+ let response = await axios.post(
142
+ `${baseUrl}/trace?api_key=request-secret&visible=yes`,
143
+ {
144
+ message: 'hello',
145
+ secret: 'client-secret'
146
+ },
147
+ {
148
+ headers: {
149
+ Authorization: 'Bearer client-token',
150
+ 'X-Api-Key': 'client-api-key'
151
+ }
152
+ }
153
+ );
154
+
155
+ return {
156
+ output: {
157
+ ok: response.data.ok,
158
+ status: response.status,
159
+ traceCount: ctx.getHttpTraces().length
160
+ },
161
+ message: 'traced'
162
+ };
163
+ })
164
+ .build();
165
+
166
+ return Slate.create({
167
+ spec,
168
+ tools: [traceTool],
169
+ triggers: []
170
+ });
171
+ };
172
+
173
+ let createTraceErrorSlate = (baseUrl: string) => {
174
+ let config = SlateConfig.create(z.object({}));
175
+ let auth = SlateAuth.create<{}>().output(z.object({}));
176
+
177
+ let spec = SlateSpecification.create({
178
+ key: 'trace-error-slate',
179
+ name: 'Trace Error Slate',
180
+ description: 'A slate that traces failed HTTP requests',
181
+ config,
182
+ auth
183
+ });
184
+
185
+ let traceTool = SlateTool.create(spec, {
186
+ key: 'trace_http_error',
187
+ name: 'Trace HTTP Error'
188
+ })
189
+ .input(z.object({}))
190
+ .output(
191
+ z.object({
192
+ ok: z.boolean()
193
+ })
194
+ )
195
+ .handleInvocation(async () => {
196
+ await axios.post(
197
+ `${baseUrl}/trace-error?client_secret=request-secret`,
198
+ new URLSearchParams({
199
+ grant_type: 'authorization_code',
200
+ client_secret: 'client-secret'
201
+ }).toString(),
202
+ {
203
+ headers: {
204
+ 'Content-Type': 'application/x-www-form-urlencoded',
205
+ Authorization: 'Bearer client-token'
206
+ }
207
+ }
208
+ );
209
+
210
+ return {
211
+ output: {
212
+ ok: true
213
+ },
214
+ message: 'should not reach'
215
+ };
216
+ })
217
+ .build();
218
+
219
+ return Slate.create({
220
+ spec,
221
+ tools: [traceTool],
222
+ triggers: []
223
+ });
224
+ };
225
+
226
+ let createTriggerTraceSlate = () => {
227
+ let config = SlateConfig.create(z.object({}));
228
+ let auth = SlateAuth.create<{}>().output(z.object({}));
229
+
230
+ let spec = SlateSpecification.create({
231
+ key: 'trigger-trace-slate',
232
+ name: 'Trigger Trace Slate',
233
+ description: 'A slate that traces trigger execution',
234
+ config,
235
+ auth
236
+ });
237
+
238
+ let pollingTrigger = SlateTrigger.create(spec, {
239
+ key: 'poll_trigger',
240
+ name: 'Poll Trigger'
241
+ })
242
+ .input(
243
+ z.object({
244
+ id: z.string()
245
+ })
246
+ )
247
+ .output(
248
+ z.object({
249
+ type: z.string(),
250
+ value: z.string()
251
+ })
252
+ )
253
+ .polling({
254
+ pollEvents: async () => ({
255
+ inputs: [{ id: 'poll-1' }, { id: 'poll-2' }]
256
+ }),
257
+ handleEvent: async ctx => ({
258
+ id: ctx.input.id,
259
+ type: 'poll.event',
260
+ output: {
261
+ type: 'poll.event',
262
+ value: ctx.input.id
263
+ }
264
+ })
265
+ })
266
+ .build();
267
+
268
+ let webhookTrigger = SlateTrigger.create(spec, {
269
+ key: 'webhook_trigger',
270
+ name: 'Webhook Trigger'
271
+ })
272
+ .input(
273
+ z.object({
274
+ id: z.string()
275
+ })
276
+ )
277
+ .output(
278
+ z.object({
279
+ type: z.string(),
280
+ value: z.string()
281
+ })
282
+ )
283
+ .webhook({
284
+ handleRequest: async () => ({
285
+ inputs: [{ id: 'webhook-1' }, { id: 'webhook-2' }]
286
+ }),
287
+ handleEvent: async ctx => ({
288
+ id: ctx.input.id,
289
+ type: 'webhook.event',
290
+ output: {
291
+ type: 'webhook.event',
292
+ value: ctx.input.id
293
+ }
294
+ })
295
+ })
296
+ .build();
297
+
298
+ return Slate.create({
299
+ spec,
300
+ tools: [],
301
+ triggers: [pollingTrigger, webhookTrigger]
302
+ });
303
+ };
304
+
305
+ type OAuthConfig = {
306
+ loginHost: string;
307
+ };
308
+
309
+ type OAuthOutput = {
310
+ token: string;
311
+ refreshToken?: string;
312
+ };
313
+
314
+ type SeenOAuthConfig = Partial<
315
+ Record<'authorizationUrl' | 'callback' | 'refresh' | 'profile', OAuthConfig>
316
+ >;
317
+
318
+ let getRequiredOAuthConfig = (ctx: { config?: Record<string, unknown> }): OAuthConfig => {
319
+ expect(ctx.config).toBeDefined();
320
+ return ctx.config as OAuthConfig;
321
+ };
322
+
323
+ let createOauthConfigSlate = (seenConfig: SeenOAuthConfig) => {
324
+ let config = SlateConfig.create(
325
+ z.object({
326
+ loginHost: z.string()
327
+ })
328
+ );
329
+
330
+ let auth = SlateAuth.create<{ token: string; refreshToken?: string }>()
331
+ .output(
332
+ z.object({
333
+ token: z.string(),
334
+ refreshToken: z.string().optional()
335
+ })
336
+ )
337
+ .addOauth({
338
+ type: 'auth.oauth',
339
+ key: 'oauth',
340
+ name: 'OAuth',
341
+ scopes: [],
342
+ inputSchema: z.object({}),
343
+ getAuthorizationUrl: async ctx => {
344
+ let config = getRequiredOAuthConfig(ctx);
345
+ seenConfig.authorizationUrl = config;
346
+ return {
347
+ url: `https://${config.loginHost}/authorize`,
348
+ callbackState: {
349
+ loginHost: config.loginHost
350
+ }
351
+ };
352
+ },
353
+ handleCallback: async ctx => {
354
+ let config = getRequiredOAuthConfig(ctx);
355
+ seenConfig.callback = config;
356
+ return {
357
+ output: {
358
+ token: `token:${config.loginHost}`,
359
+ refreshToken: 'refresh-token'
360
+ }
361
+ };
362
+ },
363
+ handleTokenRefresh: async (ctx: {
364
+ output: OAuthOutput;
365
+ config?: Record<string, unknown>;
366
+ }) => {
367
+ let config = getRequiredOAuthConfig(ctx);
368
+ seenConfig.refresh = config;
369
+ return {
370
+ output: {
371
+ ...ctx.output,
372
+ token: `refreshed:${config.loginHost}`
373
+ }
374
+ };
375
+ },
376
+ getProfile: async (ctx: { config?: Record<string, unknown> }) => {
377
+ let config = getRequiredOAuthConfig(ctx);
378
+ seenConfig.profile = config;
379
+ return {
380
+ profile: {
381
+ loginHost: config.loginHost
382
+ }
383
+ };
384
+ }
385
+ });
386
+
387
+ let spec = SlateSpecification.create({
388
+ key: 'oauth-config-slate',
389
+ name: 'OAuth Config Slate',
390
+ description: 'A slate that reads config in OAuth callbacks',
391
+ config,
392
+ auth
393
+ });
394
+
395
+ return Slate.create({
396
+ spec,
397
+ tools: [],
398
+ triggers: []
399
+ });
400
+ };
401
+
402
+ describe('@slates/client local transport', () => {
403
+ it('discovers auth/config and invokes tools with session state', async () => {
404
+ let slate = createDemoSlate();
405
+ let client = createSlatesClient({
406
+ transport: createLocalSlateTransport({ slate })
407
+ });
408
+
409
+ let provider = await client.identify();
410
+ expect(provider.provider.id).toBe('demo-slate');
411
+
412
+ let actions = await client.listTools();
413
+ expect(actions).toHaveLength(1);
414
+ expect(actions[0]!.id).toBe('echo');
415
+ expect(actions[0]!.scopes).toEqual({
416
+ AND: [
417
+ {
418
+ OR: ['scope:echo', 'scope:echo:admin']
419
+ }
420
+ ]
421
+ });
422
+
423
+ let configSchema = await client.getConfigSchema();
424
+ expect(configSchema.schema.properties.prefix.type).toBe('string');
425
+
426
+ let defaultConfig = await client.getDefaultConfig();
427
+ expect(defaultConfig.config).toEqual({ prefix: 'Hello' });
428
+
429
+ let authMethods = await client.listAuthMethods();
430
+ expect(authMethods.authenticationMethods).toHaveLength(1);
431
+ expect(authMethods.authenticationMethods[0]!.type).toBe('auth.token');
432
+
433
+ let defaultInput = await client.getDefaultAuthInput('token_auth');
434
+ expect(defaultInput.input).toEqual({ token: 'default-token' });
435
+
436
+ let changedInput = await client.updateAuthInput({
437
+ authenticationMethodId: 'token_auth',
438
+ previousInput: null,
439
+ newInput: { token: ' trimmed-token ' }
440
+ });
441
+ expect(changedInput.input).toEqual({ token: 'trimmed-token' });
442
+
443
+ let authOutput = await client.getAuthOutput({
444
+ authenticationMethodId: 'token_auth',
445
+ input: changedInput.input ?? { token: '' }
446
+ });
447
+ expect(authOutput.output).toEqual({ token: 'trimmed-token' });
448
+
449
+ client.setConfig({ prefix: 'Hi' });
450
+ client.setAuth({
451
+ authenticationMethodId: 'token_auth',
452
+ output: authOutput.output
453
+ });
454
+
455
+ let result = await client.invokeTool('echo', { name: 'Tobias' });
456
+ expect(result.output).toEqual({
457
+ greeting: 'Hi Tobias',
458
+ token: 'trimmed-token'
459
+ });
460
+ expect(client.state.session?.id).toBeTruthy();
461
+ });
462
+
463
+ it('emits structured traces for provider callbacks', async () => {
464
+ let slate = createDemoSlate();
465
+ let logs: SlateLogEntry[] = [];
466
+ let client = createSlatesClient({
467
+ transport: createLocalSlateTransport({
468
+ slate,
469
+ listeners: [
470
+ entries => {
471
+ logs.push(...entries);
472
+ }
473
+ ]
474
+ })
475
+ });
476
+
477
+ await client.identify();
478
+ await client.getDefaultConfig();
479
+ let changedInput = await client.updateAuthInput({
480
+ authenticationMethodId: 'token_auth',
481
+ previousInput: null,
482
+ newInput: { token: ' traced-token ' }
483
+ });
484
+ let authOutput = await client.getAuthOutput({
485
+ authenticationMethodId: 'token_auth',
486
+ input: changedInput.input ?? { token: '' }
487
+ });
488
+
489
+ client.setConfig({ prefix: 'Hi' });
490
+ client.setAuth({
491
+ authenticationMethodId: 'token_auth',
492
+ output: authOutput.output
493
+ });
494
+ await client.invokeTool('echo', { name: 'Tracing' });
495
+
496
+ await waitForLogs();
497
+
498
+ expect(logs).toEqual(
499
+ expect.arrayContaining([
500
+ expect.objectContaining({
501
+ type: 'info',
502
+ message: 'Getting default config',
503
+ data: expect.objectContaining({
504
+ providerId: 'demo-slate',
505
+ component: 'config',
506
+ functionName: 'getDefaultConfig',
507
+ phase: 'start'
508
+ })
509
+ }),
510
+ expect.objectContaining({
511
+ type: 'info',
512
+ message: 'Authentication input change handler completed',
513
+ data: expect.objectContaining({
514
+ providerId: 'demo-slate',
515
+ component: 'auth',
516
+ functionName: 'onInputChanged',
517
+ phase: 'success',
518
+ authenticationMethodId: 'token_auth',
519
+ returnedInput: true
520
+ })
521
+ }),
522
+ expect.objectContaining({
523
+ type: 'info',
524
+ message: 'Completed tool "Echo" (echo)',
525
+ data: expect.objectContaining({
526
+ providerId: 'demo-slate',
527
+ component: 'action',
528
+ functionName: 'handleInvocation',
529
+ phase: 'success',
530
+ actionId: 'echo',
531
+ hasMessage: true,
532
+ actionResultMessage: 'done'
533
+ })
534
+ })
535
+ ])
536
+ );
537
+ });
538
+
539
+ it('emits human-readable trigger event count logs', async () => {
540
+ let logs: SlateLogEntry[] = [];
541
+ let client = createSlatesClient({
542
+ transport: createLocalSlateTransport({
543
+ slate: createTriggerTraceSlate(),
544
+ listeners: [
545
+ entries => {
546
+ logs.push(...entries);
547
+ }
548
+ ]
549
+ }),
550
+ state: {
551
+ config: {}
552
+ }
553
+ });
554
+
555
+ client.ensureSession();
556
+
557
+ await client.request('slates/action.trigger.poll_events', {
558
+ actionId: 'poll_trigger',
559
+ state: null
560
+ });
561
+
562
+ await client.request('slates/action.trigger.webhook_handle', {
563
+ actionId: 'webhook_trigger',
564
+ url: 'https://example.com/webhook',
565
+ method: 'POST',
566
+ headers: {
567
+ 'content-type': 'application/json'
568
+ },
569
+ body: null,
570
+ state: null
571
+ });
572
+
573
+ await waitForLogs();
574
+
575
+ expect(logs).toEqual(
576
+ expect.arrayContaining([
577
+ expect.objectContaining({
578
+ type: 'info',
579
+ message: 'Polled 2 event(s) for trigger "Poll Trigger" (poll_trigger)',
580
+ data: expect.objectContaining({
581
+ component: 'action',
582
+ functionName: 'pollEvents',
583
+ inputCount: 2,
584
+ actionId: 'poll_trigger'
585
+ })
586
+ }),
587
+ expect.objectContaining({
588
+ type: 'info',
589
+ message:
590
+ 'Received 2 webhook event(s) for trigger "Webhook Trigger" (webhook_trigger)',
591
+ data: expect.objectContaining({
592
+ component: 'action',
593
+ functionName: 'handleRequest',
594
+ inputCount: 2,
595
+ actionId: 'webhook_trigger'
596
+ })
597
+ })
598
+ ])
599
+ );
600
+ });
601
+
602
+ it('passes current profile config into OAuth callbacks', async () => {
603
+ let seenConfig: SeenOAuthConfig = {};
604
+ let client = createSlatesClient({
605
+ transport: createLocalSlateTransport({
606
+ slate: createOauthConfigSlate(seenConfig)
607
+ }),
608
+ state: {
609
+ config: {
610
+ loginHost: 'sandbox.example.com'
611
+ }
612
+ }
613
+ });
614
+
615
+ let authorizationUrl = await client.getAuthorizationUrl({
616
+ authenticationMethodId: 'oauth',
617
+ redirectUri: 'http://localhost:3000/callback',
618
+ state: 'state-1',
619
+ input: {},
620
+ clientId: 'client-id',
621
+ clientSecret: 'client-secret',
622
+ scopes: []
623
+ });
624
+
625
+ expect(authorizationUrl.authorizationUrl).toBe('https://sandbox.example.com/authorize');
626
+ expect(authorizationUrl.callbackState).toEqual({
627
+ loginHost: 'sandbox.example.com'
628
+ });
629
+
630
+ let callback = await client.handleAuthorizationCallback({
631
+ authenticationMethodId: 'oauth',
632
+ code: 'code-1',
633
+ state: 'state-1',
634
+ redirectUri: 'http://localhost:3000/callback',
635
+ input: {},
636
+ clientId: 'client-id',
637
+ clientSecret: 'client-secret',
638
+ scopes: [],
639
+ callbackState: authorizationUrl.callbackState
640
+ });
641
+
642
+ expect(callback.output).toEqual({
643
+ token: 'token:sandbox.example.com',
644
+ refreshToken: 'refresh-token'
645
+ });
646
+
647
+ let refreshed = await client.refreshToken({
648
+ authenticationMethodId: 'oauth',
649
+ output: callback.output,
650
+ input: {},
651
+ clientId: 'client-id',
652
+ clientSecret: 'client-secret',
653
+ scopes: []
654
+ });
655
+
656
+ expect(refreshed.output.token).toBe('refreshed:sandbox.example.com');
657
+
658
+ let profile = await client.getAuthProfile({
659
+ authenticationMethodId: 'oauth',
660
+ output: refreshed.output,
661
+ input: {},
662
+ scopes: []
663
+ });
664
+
665
+ expect(profile.profile).toEqual({
666
+ loginHost: 'sandbox.example.com'
667
+ });
668
+ expect(seenConfig).toEqual({
669
+ authorizationUrl: { loginHost: 'sandbox.example.com' },
670
+ callback: { loginHost: 'sandbox.example.com' },
671
+ refresh: { loginHost: 'sandbox.example.com' },
672
+ profile: { loginHost: 'sandbox.example.com' }
673
+ });
674
+ });
675
+
676
+ it('throws structured protocol errors for provider responses', async () => {
677
+ let client = createSlatesClient({
678
+ transport: {
679
+ async send(messages) {
680
+ let request = messages.find(message => 'id' in message && message.id);
681
+ return [
682
+ {
683
+ jsonrpc: '2.0',
684
+ id: (request as { id: string }).id,
685
+ error: {
686
+ code: 'resource.not_found',
687
+ kind: 'upstream',
688
+ message: 'Resource contact_123 was not found',
689
+ status: 404,
690
+ provider: {
691
+ service: 'demo',
692
+ operation: 'failing.invoke'
693
+ },
694
+ baggage: {
695
+ resourceId: 'contact_123'
696
+ }
697
+ }
698
+ } as any
699
+ ];
700
+ }
701
+ }
702
+ });
703
+
704
+ let promise = client.identify();
705
+
706
+ await expect(promise).rejects.toBeInstanceOf(SlateProtocolError);
707
+
708
+ try {
709
+ await promise;
710
+ } catch (error) {
711
+ expect(error).toBeInstanceOf(SlateProtocolError);
712
+ expect((error as SlateProtocolError).data).toMatchObject({
713
+ code: 'resource.not_found',
714
+ kind: 'upstream',
715
+ message: 'Resource contact_123 was not found',
716
+ status: 404,
717
+ provider: {
718
+ service: 'demo',
719
+ operation: 'failing.invoke'
720
+ },
721
+ baggage: {
722
+ resourceId: 'contact_123'
723
+ }
724
+ });
725
+ }
726
+ });
727
+
728
+ it('returns sanitized request traces for shared axios calls', async () => {
729
+ let server = createServer((_req, res) => {
730
+ res.statusCode = 200;
731
+ res.setHeader('Content-Type', 'application/json');
732
+ res.setHeader('X-Request-Id', 'req-123');
733
+ res.end(
734
+ JSON.stringify({
735
+ ok: true,
736
+ token: 'server-secret',
737
+ note: 'x'.repeat(11_000)
738
+ })
739
+ );
740
+ });
741
+
742
+ await new Promise<void>((resolve, reject) => {
743
+ server.once('error', reject);
744
+ server.listen(0, '127.0.0.1', () => resolve());
745
+ });
746
+
747
+ try {
748
+ let { port } = server.address() as AddressInfo;
749
+ let client = createSlatesClient({
750
+ transport: createLocalSlateTransport({
751
+ slate: createTraceSlate(`http://127.0.0.1:${port}`)
752
+ }),
753
+ state: {
754
+ config: {}
755
+ }
756
+ });
757
+
758
+ let result = await client.invokeTool('trace_http', {});
759
+
760
+ expect(result.output).toEqual({
761
+ ok: true,
762
+ status: 200,
763
+ traceCount: 1
764
+ });
765
+ expect(result.requestTraces).toHaveLength(1);
766
+
767
+ let trace = result.requestTraces?.[0];
768
+ expect(trace?.request.method).toBe('POST');
769
+ expect(trace?.request.url).toContain('visible=yes');
770
+ expect(trace?.request.url).not.toContain('request-secret');
771
+ expect(trace?.request.headers).toMatchObject({
772
+ accept: 'application/json, text/plain, */*',
773
+ 'user-agent': 'slates.dev/1.0.0 trace-slate',
774
+ 'x-slates-provider': 'trace-slate'
775
+ });
776
+ expect(trace?.request.headers).not.toHaveProperty('authorization');
777
+ expect(trace?.request.headers).not.toHaveProperty('x-api-key');
778
+ expect(trace?.request.body?.text).toContain('"secret":"[redacted]"');
779
+ expect(trace?.request.body?.text).not.toContain('client-secret');
780
+ expect(trace?.response?.headers).toMatchObject({
781
+ 'content-type': 'application/json',
782
+ 'x-request-id': 'req-123'
783
+ });
784
+ expect(trace?.response?.body?.truncated).toBe(true);
785
+ expect(trace?.response?.body?.text).toContain('"token":"[redacted]"');
786
+ expect(trace?.response?.body?.text).not.toContain('server-secret');
787
+ } finally {
788
+ await new Promise<void>((resolve, reject) => {
789
+ server.close(error => (error ? reject(error) : resolve()));
790
+ });
791
+ }
792
+ });
793
+
794
+ it('returns sanitized request traces on provider errors', async () => {
795
+ let server = createServer((_req, res) => {
796
+ res.statusCode = 400;
797
+ res.setHeader('Content-Type', 'application/json');
798
+ res.end(
799
+ JSON.stringify({
800
+ error: 'invalid_grant',
801
+ client_secret: 'server-secret'
802
+ })
803
+ );
804
+ });
805
+
806
+ await new Promise<void>((resolve, reject) => {
807
+ server.once('error', reject);
808
+ server.listen(0, '127.0.0.1', () => resolve());
809
+ });
810
+
811
+ try {
812
+ let { port } = server.address() as AddressInfo;
813
+ let client = createSlatesClient({
814
+ transport: createLocalSlateTransport({
815
+ slate: createTraceErrorSlate(`http://127.0.0.1:${port}`)
816
+ }),
817
+ state: {
818
+ config: {}
819
+ }
820
+ });
821
+
822
+ let error = await client
823
+ .invokeTool('trace_http_error', {})
824
+ .then(() => null)
825
+ .catch(err => err as SlateProtocolError);
826
+
827
+ expect(error).toBeInstanceOf(SlateProtocolError);
828
+ expect(error?.data.code).toBe('upstream.invalid_request');
829
+ expect(error?.data.requestTraces).toHaveLength(1);
830
+
831
+ let trace = error?.data.requestTraces?.[0] as Record<string, any> | undefined;
832
+ expect(trace?.request.method).toBe('POST');
833
+ expect(trace?.request.url).not.toContain('request-secret');
834
+ expect(trace?.request.headers).not.toHaveProperty('authorization');
835
+ expect(trace?.request.body?.text).toContain('client_secret=[redacted]');
836
+ expect(trace?.response?.status).toBe(400);
837
+ expect(trace?.response?.body?.text).toContain('"client_secret":"[redacted]"');
838
+ expect(trace?.response?.body?.text).not.toContain('server-secret');
839
+ } finally {
840
+ await new Promise<void>((resolve, reject) => {
841
+ server.close(error => (error ? reject(error) : resolve()));
842
+ });
843
+ }
844
+ });
845
+ });