@slates/provider 1.0.0-rc.27 → 1.0.0-rc.29

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slates/provider",
3
- "version": "1.0.0-rc.27",
3
+ "version": "1.0.0-rc.29",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { AuthConfigSecretRedactor } from '../auth/redact';
2
+ import { AuthConfigSecretRedactor, isUrlEmbeddedCredential } from '../auth/redact';
3
3
  import type { SlateContext } from '../context/context';
4
4
  import type { SlateSpecification } from '../specification/specification';
5
5
  import { tool } from './tool';
@@ -42,6 +42,7 @@ export let getFileUrlTool = <ConfigType extends {}, AuthType extends {}>(
42
42
  return {
43
43
  output: {
44
44
  ...output,
45
+ url: redactor.redactEmbedded(output.url, isUrlEmbeddedCredential),
45
46
  ...(output.headers ? { headers: redactor.redactEmbedded(output.headers) } : {}),
46
47
  ...(output.query ? { query: redactor.redactEmbedded(output.query) } : {})
47
48
  },
@@ -31,6 +31,39 @@ export type SlateTriggerGroupPollHandler<ConfigType extends {}, AuthType extends
31
31
  updatedState?: any;
32
32
  }>;
33
33
 
34
+ export interface SlateGatewayEvent {
35
+ payload: any;
36
+ idempotencyKey?: string;
37
+ }
38
+
39
+ export type SlateTriggerGroupGatewayConnectHandler<
40
+ ConfigType extends {},
41
+ AuthType extends {}
42
+ > = (
43
+ context: SlateContext<ConfigType, AuthType, { state: any | null }>
44
+ ) => Promise<{ url: string; state?: any }>;
45
+
46
+ /** `closed` is set, with no frames, when the socket closed unexpectedly. */
47
+ export type SlateTriggerGroupGatewayReceiveHandler<
48
+ ConfigType extends {},
49
+ AuthType extends {}
50
+ > = (
51
+ context: SlateContext<
52
+ ConfigType,
53
+ AuthType,
54
+ { state: any | null; frames: string[]; closed?: { code: number; reason: string } | null }
55
+ >
56
+ ) => Promise<{
57
+ state?: any;
58
+ send?: string[];
59
+ events?: SlateGatewayEvent[];
60
+ /** With `expectsAck`, the hub reconnects when no `heartbeatAcked` arrives before the next beat. */
61
+ heartbeat?: { intervalMs: number; frame: string; expectsAck?: boolean } | null;
62
+ /** Set when `frames` included the provider's heartbeat acknowledgement. */
63
+ heartbeatAcked?: boolean;
64
+ close?: { reconnect: boolean; reason?: string } | null;
65
+ }>;
66
+
34
67
  export type SlateWebhookTargetListHandler<ConfigType extends {}, AuthType extends {}> = (
35
68
  context: SlateContext<ConfigType, AuthType, { pageToken: any | null }>
36
69
  ) => Promise<{
@@ -121,6 +154,14 @@ export interface SlateTriggerGroupPollingParameters<
121
154
  pollEvents: SlateTriggerGroupPollHandler<ConfigType, AuthType>;
122
155
  }
123
156
 
157
+ export interface SlateTriggerGroupGatewayParameters<
158
+ ConfigType extends {},
159
+ AuthType extends {}
160
+ > {
161
+ connect: SlateTriggerGroupGatewayConnectHandler<ConfigType, AuthType>;
162
+ receive: SlateTriggerGroupGatewayReceiveHandler<ConfigType, AuthType>;
163
+ }
164
+
124
165
  export interface SlateTriggerGroupWebhookAutoRegistrationParameters<
125
166
  ConfigType extends {},
126
167
  AuthType extends {}
@@ -164,9 +205,10 @@ export interface SlateTriggerGroupParameters<
164
205
  AuthType extends {},
165
206
  InputType extends {} = Record<string, unknown>
166
207
  > extends SlateTriggerGroupCreateParameters<InputType> {
167
- source: 'polling' | 'webhook';
208
+ source: 'polling' | 'webhook' | 'gateway';
168
209
  polling?: SlateTriggerGroupPollingParameters<ConfigType, AuthType>;
169
210
  webhook?: SlateTriggerGroupWebhookParameters<ConfigType, AuthType>;
211
+ gateway?: SlateTriggerGroupGatewayParameters<ConfigType, AuthType>;
170
212
  routingMatchers: SlateTriggerGroupRoutingMatchersHandler<ConfigType, AuthType>;
171
213
  }
172
214
 
@@ -227,6 +269,10 @@ export class SlateTriggerGroup<
227
269
  return this._params.webhook;
228
270
  }
229
271
 
272
+ get gateway() {
273
+ return this._params.gateway;
274
+ }
275
+
230
276
  get routingMatchers() {
231
277
  return this._params.routingMatchers;
232
278
  }
@@ -237,9 +283,10 @@ export class SlateTriggerGroupBuilder<
237
283
  AuthType extends {},
238
284
  InputType extends {} = Record<string, unknown>
239
285
  > {
240
- #source: 'polling' | 'webhook' | null = null;
286
+ #source: 'polling' | 'webhook' | 'gateway' | null = null;
241
287
  #polling: SlateTriggerGroupPollingParameters<ConfigType, AuthType> | null = null;
242
288
  #webhook: SlateTriggerGroupWebhookParameters<ConfigType, AuthType> | null = null;
289
+ #gateway: SlateTriggerGroupGatewayParameters<ConfigType, AuthType> | null = null;
243
290
  #routingMatchers: SlateTriggerGroupRoutingMatchersHandler<ConfigType, AuthType> | null =
244
291
  null;
245
292
 
@@ -302,6 +349,20 @@ export class SlateTriggerGroupBuilder<
302
349
  return this;
303
350
  }
304
351
 
352
+ /** Receive events over a platform-managed persistent connection (e.g. a WebSocket gateway). */
353
+ gateway(
354
+ props: SlateTriggerGroupGatewayParameters<ConfigType, AuthType>
355
+ ): SlateTriggerGroupBuilder<ConfigType, AuthType, InputType> {
356
+ if (this.#source) {
357
+ throw new SlateDeclarationError('Trigger group invocation is already defined');
358
+ }
359
+
360
+ this.#source = 'gateway';
361
+ this.#gateway = { connect: props.connect, receive: props.receive };
362
+
363
+ return this;
364
+ }
365
+
305
366
  routingMatchers(
306
367
  handler: SlateTriggerGroupRoutingMatchersHandler<ConfigType, AuthType>
307
368
  ): SlateTriggerGroupBuilder<ConfigType, AuthType, InputType> {
@@ -312,7 +373,7 @@ export class SlateTriggerGroupBuilder<
312
373
  build(): SlateTriggerGroup<ConfigType, AuthType, InputType> {
313
374
  if (!this.#source) {
314
375
  throw new SlateDeclarationError(
315
- 'Trigger group invocation (polling or webhook) is not defined'
376
+ 'Trigger group invocation (polling, webhook, or gateway) is not defined'
316
377
  );
317
378
  }
318
379
  if (!this.#routingMatchers) {
@@ -324,6 +385,7 @@ export class SlateTriggerGroupBuilder<
324
385
  source: this.#source,
325
386
  polling: this.#polling ?? undefined,
326
387
  webhook: this.#webhook ?? undefined,
388
+ gateway: this.#gateway ?? undefined,
327
389
  routingMatchers: this.#routingMatchers
328
390
  });
329
391
  }
@@ -141,6 +141,35 @@ describe('AuthConfigSecretRedactor.redactEmbedded', () => {
141
141
  });
142
142
  });
143
143
 
144
+ describe('AuthConfigSecretRedactor filters', () => {
145
+ let authConfig = { token: 'secret-token', accountId: '123456', username: 'secret-token' };
146
+ let onlyToken = (_secret: string, path: string) => path === 'token';
147
+
148
+ it('limits whole-value replacement to fields that pass the filter', () => {
149
+ let redactor = new AuthConfigSecretRedactor(authConfig);
150
+
151
+ expect(redactor.redact(['123456', 'secret-token'], onlyToken)).toEqual([
152
+ '123456',
153
+ '$$MT$secret$authConfig$token'
154
+ ]);
155
+ expect(
156
+ redactor.redactEmbedded(['123456', 'secret-token'], () => false, onlyToken)
157
+ ).toEqual(['123456', '$$MT$secret$authConfig$token']);
158
+ expect(redactor.redactEmbedded('id 123456 / secret-token', onlyToken, onlyToken)).toBe(
159
+ 'id 123456 / $$MT$secret$authConfig$token$$'
160
+ );
161
+ });
162
+
163
+ it('keeps replacing every field, last path first, without a filter', () => {
164
+ let redactor = new AuthConfigSecretRedactor(authConfig);
165
+
166
+ expect(redactor.redact(['123456', 'secret-token'])).toEqual([
167
+ '$$MT$secret$authConfig$accountId',
168
+ '$$MT$secret$authConfig$username'
169
+ ]);
170
+ });
171
+ });
172
+
144
173
  describe('redactUrlAttachmentSecrets', () => {
145
174
  let authConfig = { token: '123SECRET', nested: { token2: 'also_secret' } };
146
175
 
@@ -167,7 +196,7 @@ describe('redactUrlAttachmentSecrets', () => {
167
196
  ]);
168
197
  });
169
198
 
170
- it('only changes header and query values, preserving metadata and the original attachment', () => {
199
+ it('only changes credential values, preserving metadata and the original attachment', () => {
171
200
  let attachment: SlateAttachment = {
172
201
  mimeType: 'application/pdf',
173
202
  attachmentHash: 'file-hash',
@@ -187,6 +216,7 @@ describe('redactUrlAttachmentSecrets', () => {
187
216
  ...original,
188
217
  content: {
189
218
  ...original.content,
219
+ url: 'https://example.com/$$MT$secret$authConfig$token$$/file.pdf',
190
220
  headers: { Authorization: 'Bearer $$MT$secret$authConfig$token$$' },
191
221
  query: { key: '$$MT$secret$authConfig$nested.token2$$-suffix' }
192
222
  }
@@ -195,7 +225,35 @@ describe('redactUrlAttachmentSecrets', () => {
195
225
  expect(attachment).toEqual(original);
196
226
  });
197
227
 
198
- it('leaves url attachments with no headers/query untouched', () => {
228
+ it('redacts a bot token embedded in the url path', () => {
229
+ let attachments = [
230
+ urlAttachment({ url: 'https://api.telegram.org/file/bot123:ABCDEFGH/photos/a.jpg' })
231
+ ];
232
+
233
+ expect(
234
+ redactUrlAttachmentSecrets(attachments, { token: '123:ABCDEFGH', botId: '12345678' })
235
+ ).toEqual([
236
+ urlAttachment({
237
+ url: 'https://api.telegram.org/file/bot$$MT$secret$authConfig$token$$/photos/a.jpg'
238
+ })
239
+ ]);
240
+ });
241
+
242
+ it('keeps non-credential and short auth values in the url', () => {
243
+ let attachments = [
244
+ urlAttachment({ url: 'https://example.com/AC12345678/12345678/abc/file.pdf' })
245
+ ];
246
+
247
+ expect(
248
+ redactUrlAttachmentSecrets(attachments, {
249
+ account_sid: 'AC12345678',
250
+ botId: '12345678',
251
+ token: 'abc'
252
+ })
253
+ ).toEqual(attachments);
254
+ });
255
+
256
+ it('leaves url attachments with no credentials untouched', () => {
199
257
  let attachments = [urlAttachment()];
200
258
  expect(redactUrlAttachmentSecrets(attachments, authConfig)).toEqual(attachments);
201
259
  });
@@ -7,29 +7,52 @@ import type { SlateAttachment } from '../action/attachment';
7
7
  */
8
8
  export let AUTH_CONFIG_SECRET_PLACEHOLDER_PREFIX = '$$MT$secret$authConfig$';
9
9
 
10
+ /** Decides whether an auth-config value (with its dotted path) may be replaced. */
11
+ export type AuthConfigSecretFilter = (secret: string, path: string) => boolean;
12
+
13
+ let ALWAYS: AuthConfigSecretFilter = () => true;
14
+
10
15
  /**
11
16
  * Maps auth-config string leaves to `$$MT$secret$authConfig$<path>` placeholders.
12
17
  * `redact` preserves whole-value substitution for HTTP traces; `redactEmbedded` also
13
18
  * replaces embedded values in attachment credentials, terminating those placeholders with `$$`.
19
+ * Both accept a `wholeValue` filter so diagnostics (traces, error text) can skip
20
+ * non-secret fields such as account IDs, emails, or subdomains; attachment callers keep
21
+ * the default, because the hub resolves every placeholder back to its value.
14
22
  */
15
23
  export class AuthConfigSecretRedactor {
16
- private readonly secretToPlaceholder = new Map<string, string>();
24
+ // A value shared by several fields keeps every path, so a filter that rejects one
25
+ // field (e.g. `username`) can still match another (e.g. `token`) with the same value.
26
+ private readonly secretToPaths = new Map<string, string[]>();
17
27
 
18
28
  public constructor(authConfig: Record<string, unknown> | undefined) {
19
29
  if (authConfig) this.buildSecretMap(authConfig);
20
30
  }
21
31
 
22
- public redact<T>(value: T): T {
32
+ public redact<T>(value: T, wholeValue: AuthConfigSecretFilter = ALWAYS): T {
23
33
  return this.transform(value, stringValue => {
24
- return this.secretToPlaceholder.get(stringValue) ?? stringValue;
34
+ return this.placeholderFor(stringValue, wholeValue) ?? stringValue;
25
35
  });
26
36
  }
27
37
 
28
- public redactEmbedded<T>(value: T): T {
29
- if (this.secretToPlaceholder.size === 0) return value;
38
+ /**
39
+ * `embeddable` limits which secrets are also replaced inside longer strings;
40
+ * `wholeValue` limits which secrets replace a string that equals them exactly.
41
+ */
42
+ public redactEmbedded<T>(
43
+ value: T,
44
+ embeddable: AuthConfigSecretFilter = ALWAYS,
45
+ wholeValue: AuthConfigSecretFilter = ALWAYS
46
+ ): T {
47
+ let embeddedPlaceholders = new Map<string, string>();
48
+ for (let secret of this.secretToPaths.keys()) {
49
+ let placeholder = this.placeholderFor(secret, embeddable);
50
+ if (placeholder) embeddedPlaceholders.set(secret, placeholder);
51
+ }
52
+ if (embeddedPlaceholders.size === 0) return this.redact(value, wholeValue);
30
53
 
31
54
  let pattern = new RegExp(
32
- [...this.secretToPlaceholder.keys()]
55
+ [...embeddedPlaceholders.keys()]
33
56
  .sort((a, b) => b.length - a.length)
34
57
  // Escape regex metacharacters so values match literally; $& inserts the matched character.
35
58
  .map(secret => secret.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
@@ -38,25 +61,34 @@ export class AuthConfigSecretRedactor {
38
61
  );
39
62
 
40
63
  return this.transform(value, stringValue => {
41
- let exactPlaceholder = this.secretToPlaceholder.get(stringValue);
64
+ let exactPlaceholder = this.placeholderFor(stringValue, wholeValue);
42
65
  if (exactPlaceholder) return exactPlaceholder;
43
66
 
44
- return stringValue.replace(
45
- pattern,
46
- secret => `${this.secretToPlaceholder.get(secret)}$$`
47
- );
67
+ return stringValue.replace(pattern, secret => `${embeddedPlaceholders.get(secret)}$$`);
48
68
  });
49
69
  }
50
70
 
71
+ // The last registered path that passes the filter wins, matching the historical
72
+ // last-write-wins behavior when no filter is given.
73
+ private placeholderFor(value: string, filter: AuthConfigSecretFilter) {
74
+ let paths = this.secretToPaths.get(value);
75
+ if (!paths) return undefined;
76
+ for (let index = paths.length - 1; index >= 0; index--) {
77
+ let path = paths[index];
78
+ if (path !== undefined && filter(value, path))
79
+ return `${AUTH_CONFIG_SECRET_PLACEHOLDER_PREFIX}${path}`;
80
+ }
81
+ return undefined;
82
+ }
83
+
51
84
  private buildSecretMap(value: unknown, path: string[] = []): void {
52
85
  if (typeof value === 'string') {
53
86
  // Never register "" -- it would replace every empty header/query value with a placeholder.
54
87
  if (value.length === 0) return;
55
88
 
56
- this.secretToPlaceholder.set(
57
- value,
58
- `${AUTH_CONFIG_SECRET_PLACEHOLDER_PREFIX}${path.join('.')}`
59
- );
89
+ let paths = this.secretToPaths.get(value);
90
+ if (paths) paths.push(path.join('.'));
91
+ else this.secretToPaths.set(value, [path.join('.')]);
60
92
  return;
61
93
  }
62
94
 
@@ -102,10 +134,17 @@ export class AuthConfigSecretRedactor {
102
134
  }
103
135
  }
104
136
 
137
+ // Credentials replaced inside URLs (e.g. Telegram's `/file/bot<token>/` path). Kept narrow:
138
+ // a placeholder in a URL makes the hub proxy the download instead of handing out the link.
139
+ let URL_CREDENTIAL_KEY = /^(?:.*[-_])?(?:token|secret|password|api[-_]?key)$/i;
140
+
141
+ export let isUrlEmbeddedCredential = (secret: string, path: string) =>
142
+ secret.length >= 8 && URL_CREDENTIAL_KEY.test(path.split('.').pop() ?? '');
143
+
105
144
  /**
106
145
  * Applied to every finalized attachment list right before it's sent over
107
146
  * `slates/action.tool.invoke` (see @slates/provider-handler). Only `type: 'url'` attachments
108
- * carry headers/query -- content/upload_reference attachments pass through untouched.
147
+ * carry credentials -- content/upload_reference attachments pass through untouched.
109
148
  */
110
149
  export let redactUrlAttachmentSecrets = (
111
150
  attachments: SlateAttachment[] | undefined,
@@ -117,18 +156,15 @@ export let redactUrlAttachmentSecrets = (
117
156
 
118
157
  return attachments.map(attachment => {
119
158
  if (attachment.content.type !== 'url') return attachment;
120
- if (!attachment.content.headers && !attachment.content.query) return attachment;
121
159
 
160
+ let { url, headers, query } = attachment.content;
122
161
  return {
123
162
  ...attachment,
124
163
  content: {
125
164
  ...attachment.content,
126
- headers: attachment.content.headers
127
- ? redactor.redactEmbedded(attachment.content.headers)
128
- : undefined,
129
- query: attachment.content.query
130
- ? redactor.redactEmbedded(attachment.content.query)
131
- : undefined
165
+ url: redactor.redactEmbedded(url, isUrlEmbeddedCredential),
166
+ ...(headers ? { headers: redactor.redactEmbedded(headers) } : {}),
167
+ ...(query ? { query: redactor.redactEmbedded(query) } : {})
132
168
  }
133
169
  };
134
170
  });