@slates/provider 1.0.0-rc.28 → 1.0.0-rc.30

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.
@@ -1,3 +1,11 @@
1
+ import { AuthConfigSecretRedactor } from '../auth/redact';
2
+ import {
3
+ isSecretFieldName,
4
+ redactAuthConfigSecretsInText,
5
+ sanitizeSecretValuesInText,
6
+ splitKeyParts
7
+ } from '../axios/trace';
8
+ import { getCurrentContext } from '../context/hook';
1
9
  import { DEFAULT_CODE, DEFAULT_MESSAGES } from './defaults';
2
10
  import type {
3
11
  SlateErrorInput,
@@ -112,26 +120,273 @@ export let mergeSlateErrorData = (
112
120
  };
113
121
  };
114
122
 
115
- export let sanitizeForBaggage = (value: unknown, depth = 0): unknown => {
116
- if (value == null) return value;
117
- if (depth >= 4) return '[truncated]';
118
- if (typeof value === 'string') {
119
- return value.length > 2_000 ? `${value.slice(0, 2_000)}...[truncated]` : value;
123
+ export let BAGGAGE_TRUNCATED_VALUE = '[truncated]';
124
+ export let BAGGAGE_REDACTED_VALUE = '[redacted]';
125
+ export let BAGGAGE_UNSERIALIZABLE_VALUE = '[unserializable]';
126
+
127
+ // Values nested this deep (root = depth 0) are replaced with the truncated placeholder.
128
+ export let BAGGAGE_DEFAULT_MAX_DEPTH = 4;
129
+ // Upstream response bodies keep deeper structure so nested provider error details
130
+ // (GraphQL errors[].extensions.*, QuickBooks Fault.Error[].Detail, validation error
131
+ // lists) reach message extractors and telemetry.
132
+ export let BAGGAGE_RESPONSE_MAX_DEPTH = 8;
133
+ export let BAGGAGE_MAX_STRING_LENGTH = 2_000;
134
+ export let BAGGAGE_MAX_ARRAY_ITEMS = 20;
135
+ export let BAGGAGE_MAX_OBJECT_KEYS = 30;
136
+ // Upper bound for the JSON-serialized size of one sanitized value. Entries past
137
+ // the budget are dropped (and a string that crosses it is cut), so a single call
138
+ // serializes to at most this many characters plus a small fixed overhead.
139
+ export let BAGGAGE_MAX_SERIALIZED_SIZE = 16_384;
140
+
141
+ let BAGGAGE_STRING_TRUNCATION_SUFFIX = '...[truncated]';
142
+ // Keep budget cuts from producing near-empty strings.
143
+ let BAGGAGE_MIN_STRING_SLICE = 32;
144
+
145
+ // Free-text patterns are scanned over at most this many characters (the kept
146
+ // string length plus a margin), which bounds their cost on adversarial input.
147
+ let BAGGAGE_SCAN_LENGTH = BAGGAGE_MAX_STRING_LENGTH + 100;
148
+ // Literal auth-config secrets are matched over a longer prefix so a long token that
149
+ // straddles the scan boundary is still replaced before the string is cut.
150
+ let BAGGAGE_REDACTOR_SCAN_LENGTH = BAGGAGE_MAX_STRING_LENGTH * 4;
151
+ // Once fewer characters than the smallest entry (`"k":0,`) remain, stop walking keys.
152
+ let BAGGAGE_MIN_ENTRY_COST = 6;
153
+
154
+ export type SanitizeForBaggageOptions = {
155
+ maxDepth?: number;
156
+ maxSerializedSize?: number;
157
+ // Replaces literal auth-config values. Defaults to the current Slate context's auth
158
+ // config when one is available; `null` disables it.
159
+ redactor?: AuthConfigSecretRedactor | null;
160
+ };
161
+
162
+ // Returns a redactor for the current Slate context's auth config, or undefined when
163
+ // no context is active (or it cannot be read).
164
+ export let getCurrentAuthConfigRedactor = (): AuthConfigSecretRedactor | undefined => {
165
+ try {
166
+ let authConfig = getCurrentContext()._getAuthConfigForRedaction();
167
+ return authConfig ? new AuthConfigSecretRedactor(authConfig) : undefined;
168
+ } catch {
169
+ return undefined;
170
+ }
171
+ };
172
+
173
+ // sanitizeErrorText keeps at most this many characters. It scans a small margin past
174
+ // the cut so a credential straddling it is still recognized before the text is cut.
175
+ export let ERROR_TEXT_MAX_LENGTH = BAGGAGE_MAX_STRING_LENGTH * 4;
176
+ let ERROR_TEXT_SCAN_MARGIN = 512;
177
+
178
+ // Free-text scrub for human-readable error text (messages, details): literal
179
+ // auth-config secrets first, then known credential formats and secret-named pairs.
180
+ // Input past ERROR_TEXT_MAX_LENGTH is cut, which also bounds the pattern cost.
181
+ // `redactor`: undefined uses the current Slate context's auth config, null skips it.
182
+ // Never throws; a failing redactor falls back to the pattern scrub alone.
183
+ export let sanitizeErrorText = (
184
+ text: string,
185
+ redactor?: AuthConfigSecretRedactor | null
186
+ ): string => {
187
+ let activeRedactor = redactor === undefined ? getCurrentAuthConfigRedactor() : redactor;
188
+ let truncated = text.length > ERROR_TEXT_MAX_LENGTH;
189
+ let scanned = truncated
190
+ ? text.slice(0, ERROR_TEXT_MAX_LENGTH + ERROR_TEXT_SCAN_MARGIN)
191
+ : text;
192
+
193
+ let result = scanned;
194
+ if (activeRedactor) {
195
+ try {
196
+ result = redactAuthConfigSecretsInText(result, activeRedactor);
197
+ } catch {
198
+ result = scanned;
199
+ }
200
+ }
201
+ result = sanitizeSecretValuesInText(result);
202
+
203
+ return truncated
204
+ ? `${result.slice(0, ERROR_TEXT_MAX_LENGTH)}${BAGGAGE_STRING_TRUNCATION_SUFFIX}`
205
+ : result;
206
+ };
207
+
208
+ // Numbers under these keys are themselves secrets (PINs, card data, numeric
209
+ // verification codes, numeric keys and secrets); other numeric and boolean values under
210
+ // secret-named keys (`token: true`, `session: 3`) are flags or counts and are kept.
211
+ let NUMERIC_SECRET_KEY_PATTERN =
212
+ /(?:^|_)(?:pin|passcode|password|passwd|pwd|otp|totp|cvv|cvc|ccv|pan|ssn|card_number|card_code|cc_num(?:ber)?)(?:_|$)|(?:^|_)(?:code|secret|key)$/;
213
+
214
+ // Validation messages under secret-named fields (`{password: "is too short"}`,
215
+ // `{token: ["This field is required."]}`) are kept when they are clearly prose:
216
+ // three or more short words, no token-like word (20+ characters, or letters mixed
217
+ // with digits), bounded length, and validation vocabulary. Multi-word passphrases rarely use that vocabulary, so a string that is
218
+ // merely several words is still redacted.
219
+ let VALIDATION_VOCABULARY_PATTERN =
220
+ /\b(?:required|invalid|must|blank|empty|missing|too (?:short|long|weak|common|simple)|characters?|(?:does not|doesn't|do not|don't) match|cannot be|can't be|not allowed|not valid|at least|at most|incorrect|already (?:taken|used|exists|in use)|has expired|is expired)\b/i;
221
+
222
+ let isValidationProse = (value: string) => {
223
+ let text = value.trim();
224
+ if (text.length > 300) return false;
225
+ let words = text.split(/\s+/);
226
+ if (words.length < 3) return false;
227
+ if (words.some(word => word.length > 20 || (/[a-z]/i.test(word) && /\d/.test(word)))) {
228
+ return false;
229
+ }
230
+ return VALIDATION_VOCABULARY_PATTERN.test(text);
231
+ };
232
+
233
+ let isKeptUnderSecretKey = (key: string, value: unknown) => {
234
+ if (typeof value === 'boolean') return true;
235
+ if (typeof value === 'number')
236
+ return !NUMERIC_SECRET_KEY_PATTERN.test(splitKeyParts(key).join('_'));
237
+ if (typeof value === 'string') return isValidationProse(value);
238
+ if (Array.isArray(value)) {
239
+ return (
240
+ value.length > 0 &&
241
+ value.length <= BAGGAGE_MAX_ARRAY_ITEMS &&
242
+ value.every(item => typeof item === 'string' && isValidationProse(item))
243
+ );
244
+ }
245
+ return false;
246
+ };
247
+
248
+ let jsonLength = (value: string) => JSON.stringify(value).length;
249
+
250
+ type BaggageState = {
251
+ remaining: number;
252
+ redactor?: AuthConfigSecretRedactor;
253
+ };
254
+
255
+ let redactLiteralSecrets = (value: string, redactor?: AuthConfigSecretRedactor) => {
256
+ if (!redactor) return value;
257
+ try {
258
+ return redactAuthConfigSecretsInText(
259
+ value.slice(0, BAGGAGE_REDACTOR_SCAN_LENGTH),
260
+ redactor
261
+ );
262
+ } catch {
263
+ return value;
264
+ }
265
+ };
266
+
267
+ let sanitizeBaggageString = (value: string, state: BaggageState) => {
268
+ // Redact literal auth-config secrets and known secret formats before cutting, so a
269
+ // cut cannot split a token into a fragment the value patterns no longer recognize.
270
+ let literalRedacted = redactLiteralSecrets(value, state.redactor);
271
+ let text = sanitizeSecretValuesInText(literalRedacted.slice(0, BAGGAGE_SCAN_LENGTH));
272
+ if (
273
+ value.length > BAGGAGE_MAX_STRING_LENGTH ||
274
+ literalRedacted.length > BAGGAGE_MAX_STRING_LENGTH ||
275
+ text.length > BAGGAGE_MAX_STRING_LENGTH
276
+ ) {
277
+ text = `${text.slice(0, BAGGAGE_MAX_STRING_LENGTH)}${BAGGAGE_STRING_TRUNCATION_SUFFIX}`;
278
+ }
279
+
280
+ let cost = jsonLength(text);
281
+ if (cost > state.remaining) {
282
+ let sliceLength =
283
+ state.remaining - BAGGAGE_STRING_TRUNCATION_SUFFIX.length - 2 - (cost - text.length);
284
+ text =
285
+ sliceLength >= BAGGAGE_MIN_STRING_SLICE
286
+ ? `${text.slice(0, sliceLength)}${BAGGAGE_STRING_TRUNCATION_SUFFIX}`
287
+ : BAGGAGE_TRUNCATED_VALUE;
288
+ cost = jsonLength(text);
289
+ if (cost > state.remaining && text !== BAGGAGE_TRUNCATED_VALUE) {
290
+ text = BAGGAGE_TRUNCATED_VALUE;
291
+ cost = jsonLength(text);
292
+ }
293
+ }
294
+
295
+ state.remaining -= cost;
296
+ return text;
297
+ };
298
+
299
+ let sanitizeBaggageValue = (
300
+ value: unknown,
301
+ depth: number,
302
+ maxDepth: number,
303
+ state: BaggageState
304
+ ): unknown => {
305
+ if (value === undefined) return value;
306
+ if (value === null) {
307
+ state.remaining -= 4;
308
+ return value;
309
+ }
310
+ if (depth >= maxDepth) {
311
+ state.remaining -= jsonLength(BAGGAGE_TRUNCATED_VALUE);
312
+ return BAGGAGE_TRUNCATED_VALUE;
120
313
  }
121
- if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
314
+ if (typeof value === 'string') return sanitizeBaggageString(value, state);
315
+ if (typeof value === 'number' || typeof value === 'boolean') {
316
+ state.remaining -= String(value).length;
122
317
  return value;
123
318
  }
319
+ // JSON.stringify throws on bigint, so it is stored as a string.
320
+ if (typeof value === 'bigint') return sanitizeBaggageString(value.toString(), state);
124
321
  if (Array.isArray(value)) {
125
- return value.slice(0, 20).map(item => sanitizeForBaggage(item, depth + 1));
322
+ state.remaining -= 2;
323
+ let items: unknown[] = [];
324
+ for (let item of value.slice(0, BAGGAGE_MAX_ARRAY_ITEMS)) {
325
+ if (state.remaining <= 0) break;
326
+ state.remaining -= 1;
327
+ if (item === undefined) {
328
+ // Serialized as `null` inside arrays.
329
+ state.remaining -= 4;
330
+ items.push(null);
331
+ continue;
332
+ }
333
+ items.push(sanitizeBaggageValue(item, depth + 1, maxDepth, state));
334
+ }
335
+ return items;
126
336
  }
127
337
  if (isRecord(value)) {
128
- return Object.fromEntries(
129
- Object.entries(value)
130
- .slice(0, 30)
131
- .map(([key, entry]) => [key, sanitizeForBaggage(entry, depth + 1)])
132
- );
338
+ state.remaining -= 2;
339
+ let entries: [string, unknown][] = [];
340
+ for (let key of Object.keys(value).slice(0, BAGGAGE_MAX_OBJECT_KEYS)) {
341
+ if (state.remaining < BAGGAGE_MIN_ENTRY_COST) break;
342
+ // Key, colon, and separator are charged up front; an entry whose key does not
343
+ // fit is skipped so shorter later keys can still be kept.
344
+ let keyCost = jsonLength(key) + 2;
345
+ if (state.remaining - keyCost <= 0) continue;
346
+ state.remaining -= keyCost;
347
+
348
+ try {
349
+ // Property access is per entry so one throwing getter only loses that entry.
350
+ let entry = value[key];
351
+
352
+ if (entry != null && isSecretFieldName(key) && !isKeptUnderSecretKey(key, entry)) {
353
+ state.remaining -= jsonLength(BAGGAGE_REDACTED_VALUE);
354
+ entries.push([key, BAGGAGE_REDACTED_VALUE]);
355
+ continue;
356
+ }
357
+
358
+ entries.push([key, sanitizeBaggageValue(entry, depth + 1, maxDepth, state)]);
359
+ } catch {
360
+ state.remaining -= jsonLength(BAGGAGE_UNSERIALIZABLE_VALUE);
361
+ entries.push([key, BAGGAGE_UNSERIALIZABLE_VALUE]);
362
+ }
363
+ }
364
+ return Object.fromEntries(entries);
365
+ }
366
+ return sanitizeBaggageString(String(value), state);
367
+ };
368
+
369
+ // Bounds and redacts arbitrary values (upstream response bodies, service error data,
370
+ // thrown non-Error values) before they are stored in SlateError baggage, which is
371
+ // serialized to the platform. Secret-named keys are redacted at every depth, strings
372
+ // are scrubbed for credential values and (inside a Slate context) literal auth-config
373
+ // secrets; other personal data is left to platform-side redaction.
374
+ export let sanitizeForBaggage = (
375
+ value: unknown,
376
+ options: SanitizeForBaggageOptions = {}
377
+ ): unknown => {
378
+ try {
379
+ return sanitizeBaggageValue(value, 0, options.maxDepth ?? BAGGAGE_DEFAULT_MAX_DEPTH, {
380
+ remaining: options.maxSerializedSize ?? BAGGAGE_MAX_SERIALIZED_SIZE,
381
+ redactor:
382
+ options.redactor === null
383
+ ? undefined
384
+ : (options.redactor ?? getCurrentAuthConfigRedactor())
385
+ });
386
+ } catch {
387
+ // Throwing getters or Proxy traps must not mask the error being mapped.
388
+ return BAGGAGE_UNSERIALIZABLE_VALUE;
133
389
  }
134
- return String(value);
135
390
  };
136
391
 
137
392
  export let normalizeSlateErrorInput = (input: SlateErrorInput): SlateErrorResponse => {
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { z } from 'zod';
3
- import { SlateTrigger, SlateTriggerGroup } from '../action';
3
+ import { SlateTool, SlateTrigger, SlateTriggerGroup, toolCategory } from '../action';
4
4
  import { SlateAuth } from '../auth';
5
5
  import { SlateConfig } from '../config';
6
6
  import { Slate } from './slate';
@@ -117,3 +117,155 @@ describe('Slate.create', () => {
117
117
  expect(slate.triggerGroups).toEqual([]);
118
118
  });
119
119
  });
120
+
121
+ let createTestTool = (spec: ReturnType<typeof createTestSpec>, key: string) =>
122
+ SlateTool.create(spec, { key, name: key })
123
+ .input(z.object({}))
124
+ .output(z.object({}))
125
+ .handleInvocation(async () => ({ output: {}, message: 'ok' }))
126
+ .build();
127
+
128
+ describe('Slate.create tool categories', () => {
129
+ it('resolves membership in declared category order', () => {
130
+ let spec = createTestSpec();
131
+ let records = createTestTool(spec, 'records');
132
+ let report = createTestTool(spec, 'report');
133
+ let other = createTestTool(spec, 'other');
134
+
135
+ let slate = Slate.create({
136
+ spec,
137
+ tools: [records, report, other],
138
+ triggers: [],
139
+ toolCategories: [
140
+ toolCategory({ key: 'crm', name: 'CRM', tools: [records, report] }),
141
+ toolCategory({ key: 'reporting', name: 'Reporting', tools: [report] })
142
+ ]
143
+ });
144
+
145
+ expect(slate.toolCategories.map(category => category.key)).toEqual(['crm', 'reporting']);
146
+ expect(slate.getToolCategoryKeys('records')).toEqual(['crm']);
147
+ expect(slate.getToolCategoryKeys('report')).toEqual(['crm', 'reporting']);
148
+ expect(slate.getToolCategoryKeys('other')).toEqual([]);
149
+ });
150
+
151
+ it('ignores a shadowed duplicate tool listed in a category', () => {
152
+ let spec = createTestSpec();
153
+ let writer = createTestTool(spec, 'dup');
154
+ let reader = createTestTool(spec, 'dup');
155
+
156
+ let slate = Slate.create({
157
+ spec,
158
+ tools: [writer, reader],
159
+ triggers: [],
160
+ toolCategories: [toolCategory({ key: 'read', name: 'Read', tools: [reader] })]
161
+ });
162
+
163
+ expect(slate.getToolCategoryKeys('dup')).toEqual([]);
164
+ });
165
+
166
+ it('returns a copy of the membership list', () => {
167
+ let spec = createTestSpec();
168
+ let tool = createTestTool(spec, 'a');
169
+ let slate = Slate.create({
170
+ spec,
171
+ tools: [tool],
172
+ triggers: [],
173
+ toolCategories: [toolCategory({ key: 'crm', name: 'CRM', tools: [tool] })]
174
+ });
175
+
176
+ slate.getToolCategoryKeys('a').push('mutated');
177
+ expect(slate.getToolCategoryKeys('a')).toEqual(['crm']);
178
+ });
179
+
180
+ it('defaults to no categories', () => {
181
+ let spec = createTestSpec();
182
+ let slate = Slate.create({ spec, tools: [createTestTool(spec, 'a')], triggers: [] });
183
+
184
+ expect(slate.toolCategories).toEqual([]);
185
+ expect(slate.getToolCategoryKeys('a')).toEqual([]);
186
+ });
187
+
188
+ it('rejects duplicate and malformed category keys', () => {
189
+ let spec = createTestSpec();
190
+ let tool = createTestTool(spec, 'a');
191
+
192
+ expect(() =>
193
+ Slate.create({
194
+ spec,
195
+ tools: [tool],
196
+ triggers: [],
197
+ toolCategories: [
198
+ toolCategory({ key: 'crm', name: 'CRM', tools: [tool] }),
199
+ toolCategory({ key: 'crm', name: 'CRM again', tools: [tool] })
200
+ ]
201
+ })
202
+ ).toThrow('Tool category "crm" is registered more than once');
203
+
204
+ expect(() =>
205
+ Slate.create({
206
+ spec,
207
+ tools: [tool],
208
+ triggers: [],
209
+ toolCategories: [toolCategory({ key: 'Zoho CRM', name: 'CRM', tools: [tool] })]
210
+ })
211
+ ).toThrow('Tool category key "Zoho CRM" must match');
212
+ });
213
+
214
+ it('accepts http(s) category images and rejects anything else', () => {
215
+ let spec = createTestSpec();
216
+ let tool = createTestTool(spec, 'a');
217
+ let slate = Slate.create({
218
+ spec,
219
+ tools: [tool],
220
+ triggers: [],
221
+ toolCategories: [
222
+ toolCategory({
223
+ key: 'crm',
224
+ name: 'CRM',
225
+ imageUrl: 'https://cdn.test/crm.svg',
226
+ tools: [tool]
227
+ })
228
+ ]
229
+ });
230
+ expect(slate.toolCategories[0]?.imageUrl).toBe('https://cdn.test/crm.svg');
231
+
232
+ expect(() =>
233
+ Slate.create({
234
+ spec,
235
+ tools: [tool],
236
+ triggers: [],
237
+ toolCategories: [
238
+ toolCategory({ key: 'crm', name: 'CRM', imageUrl: 'crm.svg', tools: [tool] })
239
+ ]
240
+ })
241
+ ).toThrow('Tool category "crm" imageUrl must be an absolute http(s) URL');
242
+ });
243
+
244
+ it('rejects a category without tools', () => {
245
+ let spec = createTestSpec();
246
+
247
+ expect(() =>
248
+ Slate.create({
249
+ spec,
250
+ tools: [createTestTool(spec, 'a')],
251
+ triggers: [],
252
+ toolCategories: [toolCategory({ key: 'crm', name: 'CRM', tools: [] })]
253
+ })
254
+ ).toThrow('Tool category "crm" must list at least one tool');
255
+ });
256
+
257
+ it('rejects unregistered member tools', () => {
258
+ let spec = createTestSpec();
259
+
260
+ expect(() =>
261
+ Slate.create({
262
+ spec,
263
+ tools: [],
264
+ triggers: [],
265
+ toolCategories: [
266
+ toolCategory({ key: 'crm', name: 'CRM', tools: [createTestTool(spec, 'stray')] })
267
+ ]
268
+ })
269
+ ).toThrow('Tool category "crm" lists tool "stray", which is not registered in this slate');
270
+ });
271
+ });
@@ -1,4 +1,4 @@
1
- import type { SlateTool, SlateTrigger, SlateTriggerGroup } from '../action';
1
+ import type { SlateTool, SlateToolCategory, SlateTrigger, SlateTriggerGroup } from '../action';
2
2
  import type { SlateAdapter } from '../adapter';
3
3
  import { SlateDeclarationError } from '../error';
4
4
  import type { SlateSpecification } from './specification';
@@ -11,7 +11,9 @@ export class Slate<ConfigType extends {}, AuthType extends {}> {
11
11
  | SlateTool<ConfigType, AuthType, any, any>
12
12
  )[],
13
13
  private readonly _adapters: SlateAdapter<ConfigType, AuthType>[],
14
- private readonly _triggerGroups: SlateTriggerGroup<ConfigType, AuthType>[]
14
+ private readonly _triggerGroups: SlateTriggerGroup<ConfigType, AuthType>[],
15
+ private readonly _toolCategories: SlateToolCategory[],
16
+ private readonly _toolCategoryKeysByActionKey: Map<string, string[]>
15
17
  ) {}
16
18
 
17
19
  static create<ConfigType extends {}, AuthType extends {}>(params: {
@@ -20,6 +22,7 @@ export class Slate<ConfigType extends {}, AuthType extends {}> {
20
22
  tools: SlateTool<ConfigType, AuthType, any, any>[];
21
23
  adapters?: SlateAdapter<ConfigType, AuthType>[];
22
24
  triggerGroups?: SlateTriggerGroup<ConfigType, AuthType>[];
25
+ toolCategories?: SlateToolCategory[];
23
26
  }) {
24
27
  let adapters = params.adapters ?? [];
25
28
  let actions = [...params.triggers, ...params.tools];
@@ -71,7 +74,17 @@ export class Slate<ConfigType extends {}, AuthType extends {}> {
71
74
  );
72
75
  }
73
76
 
74
- return new Slate(params.spec, actions, adapters, triggerGroups);
77
+ let toolCategories = params.toolCategories ?? [];
78
+ let toolCategoryKeysByActionKey = resolveToolCategoryMembership(toolCategories, actions);
79
+
80
+ return new Slate(
81
+ params.spec,
82
+ actions,
83
+ adapters,
84
+ triggerGroups,
85
+ toolCategories,
86
+ toolCategoryKeysByActionKey
87
+ );
75
88
  }
76
89
 
77
90
  get spec() {
@@ -89,4 +102,93 @@ export class Slate<ConfigType extends {}, AuthType extends {}> {
89
102
  get triggerGroups() {
90
103
  return this._triggerGroups ?? [];
91
104
  }
105
+
106
+ get toolCategories() {
107
+ return this._toolCategories ?? [];
108
+ }
109
+
110
+ // Keys of the tool categories an action belongs to, in declared category order.
111
+ getToolCategoryKeys(actionKey: string) {
112
+ return [...(this._toolCategoryKeysByActionKey?.get(actionKey) ?? [])];
113
+ }
92
114
  }
115
+
116
+ let TOOL_CATEGORY_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
117
+
118
+ let isHttpUrl = (value: string) => {
119
+ try {
120
+ let url = new URL(value);
121
+ return url.protocol === 'https:' || url.protocol === 'http:';
122
+ } catch {
123
+ return false;
124
+ }
125
+ };
126
+
127
+ type SlateActionAny = SlateTool<any, any, any, any> | SlateTrigger<any, any, any, any>;
128
+
129
+ let resolveToolCategoryMembership = (
130
+ toolCategories: SlateToolCategory[],
131
+ actions: SlateActionAny[]
132
+ ) => {
133
+ // Actions resolve by key and the first registered one wins, so a shadowed duplicate must
134
+ // not lend its categories to the action that actually runs.
135
+ let effectiveActions = new Map<string, SlateActionAny>();
136
+ for (let action of actions) {
137
+ if (!effectiveActions.has(action.key)) effectiveActions.set(action.key, action);
138
+ }
139
+ let registeredActions = new Set(actions);
140
+ let membersByCategoryKey = new Map<string, Set<string>>();
141
+
142
+ for (let category of toolCategories) {
143
+ if (!TOOL_CATEGORY_KEY_PATTERN.test(category.key)) {
144
+ throw new SlateDeclarationError(
145
+ `Tool category key "${category.key}" must match ${TOOL_CATEGORY_KEY_PATTERN}`
146
+ );
147
+ }
148
+ if (membersByCategoryKey.has(category.key)) {
149
+ throw new SlateDeclarationError(
150
+ `Tool category "${category.key}" is registered more than once`
151
+ );
152
+ }
153
+ if (!category.name?.trim()) {
154
+ throw new SlateDeclarationError(`Tool category "${category.key}" must have a name`);
155
+ }
156
+ if (category.imageUrl !== undefined && !isHttpUrl(category.imageUrl)) {
157
+ throw new SlateDeclarationError(
158
+ `Tool category "${category.key}" imageUrl must be an absolute http(s) URL`
159
+ );
160
+ }
161
+ if (!category.tools?.length) {
162
+ throw new SlateDeclarationError(
163
+ `Tool category "${category.key}" must list at least one tool`
164
+ );
165
+ }
166
+
167
+ let members = new Set<string>();
168
+ for (let tool of category.tools) {
169
+ if (!registeredActions.has(tool)) {
170
+ throw new SlateDeclarationError(
171
+ `Tool category "${category.key}" lists tool "${tool.key}", which is not registered in this slate`
172
+ );
173
+ }
174
+ if (tool.type !== 'tool') {
175
+ throw new SlateDeclarationError(
176
+ `Tool category "${category.key}" lists trigger "${tool.key}", but only tools can belong to a tool category`
177
+ );
178
+ }
179
+ if (effectiveActions.get(tool.key) === tool) members.add(tool.key);
180
+ }
181
+ membersByCategoryKey.set(category.key, members);
182
+ }
183
+
184
+ let keysByActionKey = new Map<string, string[]>();
185
+ for (let category of toolCategories) {
186
+ for (let actionKey of membersByCategoryKey.get(category.key) ?? []) {
187
+ let keys = keysByActionKey.get(actionKey) ?? [];
188
+ keys.push(category.key);
189
+ keysByActionKey.set(actionKey, keys);
190
+ }
191
+ }
192
+
193
+ return keysByActionKey;
194
+ };