@slates/provider 1.0.0-rc.32 → 1.0.0-rc.34

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.
@@ -8,6 +8,8 @@ export let SlateDefaultPollingIntervalSeconds = 60 * 10;
8
8
 
9
9
  export type SlateTriggerRoutingMatcher = Record<string, any>;
10
10
 
11
+ export type SlateTriggerGroupSelection = Record<string, any>;
12
+
11
13
  export type SlateWebhookTargetOwnership = 'single_user' | 'multi_user';
12
14
 
13
15
  export interface SlateWebhookTarget {
@@ -25,7 +27,11 @@ export type SlateTriggerGroupRoutingMatchersHandler<
25
27
  > = (context: SlateContext<ConfigType, AuthType, {}>) => Promise<SlateTriggerRoutingMatcher[]>;
26
28
 
27
29
  export type SlateTriggerGroupPollHandler<ConfigType extends {}, AuthType extends {}> = (
28
- context: SlateContext<ConfigType, AuthType, { state: any | null }>
30
+ context: SlateContext<
31
+ ConfigType,
32
+ AuthType,
33
+ { state: any | null; selection?: SlateTriggerGroupSelection }
34
+ >
29
35
  ) => Promise<{
30
36
  events: { payload: any; idempotencyKey?: string }[];
31
37
  updatedState?: any;
@@ -65,7 +71,11 @@ export type SlateTriggerGroupGatewayReceiveHandler<
65
71
  }>;
66
72
 
67
73
  export type SlateWebhookTargetListHandler<ConfigType extends {}, AuthType extends {}> = (
68
- context: SlateContext<ConfigType, AuthType, { pageToken: any | null }>
74
+ context: SlateContext<
75
+ ConfigType,
76
+ AuthType,
77
+ { pageToken: any | null; selection?: SlateTriggerGroupSelection }
78
+ >
69
79
  ) => Promise<{
70
80
  targets: SlateWebhookTarget[];
71
81
  nextPageToken: any | null;
@@ -81,11 +91,54 @@ export type SlateWebhookRegisterHandler<ConfigType extends {}, AuthType extends
81
91
  webhookTargetIdentifier: string;
82
92
  webhookTargetPayload: any;
83
93
  webhookUrl: string;
94
+ operationId?: string;
95
+ selection?: SlateTriggerGroupSelection;
96
+ deliveryConfig?: unknown;
84
97
  }
85
98
  >
86
- ) => Promise<{
99
+ ) => Promise<SlateWebhookRegistrationResult>;
100
+
101
+ export interface SlateWebhookRegistrationResult {
87
102
  webhookRegistrationIdentifier: string;
88
103
  webhookRegistrationPayload: any;
104
+ expiresAt?: string | null;
105
+ renewAfter?: string | null;
106
+ /** Identities of this registration on a delivery channel shared with other registrations. */
107
+ routingKeys?: string[];
108
+ }
109
+
110
+ export type SlateWebhookRenewHandler<ConfigType extends {}, AuthType extends {}> = (
111
+ context: SlateContext<
112
+ ConfigType,
113
+ AuthType,
114
+ {
115
+ webhookRegistrationIdentifier: string;
116
+ webhookRegistrationPayload: any;
117
+ webhookUrl: string;
118
+ webhookTargetIdentifier: string;
119
+ webhookTargetPayload: any;
120
+ operationId: string;
121
+ selection?: SlateTriggerGroupSelection;
122
+ deliveryConfig?: unknown;
123
+ }
124
+ >
125
+ ) => Promise<SlateWebhookRegistrationResult>;
126
+
127
+ export type SlateWebhookExpandHandler<ConfigType extends {}, AuthType extends {}> = (
128
+ context: SlateContext<
129
+ ConfigType,
130
+ AuthType,
131
+ {
132
+ payload: unknown | null;
133
+ state: any | null;
134
+ selection?: SlateTriggerGroupSelection;
135
+ deliveryId: string;
136
+ webhookRegistrationPayload: any;
137
+ }
138
+ >
139
+ ) => Promise<{
140
+ events: { payload: any; idempotencyKey?: string }[];
141
+ updatedState?: any;
89
142
  }>;
90
143
 
91
144
  export type SlateWebhookUnregisterHandler<ConfigType extends {}, AuthType extends {}> = (
@@ -146,6 +199,18 @@ export interface SlateWebhookSkipped {
146
199
  reason: string;
147
200
  }
148
201
 
202
+ /**
203
+ * Routing keys name the registrations a request addresses; an empty list acknowledges it
204
+ * without handing it to any. A rejection answers the sender with its status.
205
+ */
206
+ export type SlateWebhookDeliveryAuthentication =
207
+ | { routingKeys: string[]; rejected?: undefined }
208
+ | { rejected: { status: number }; routingKeys?: undefined };
209
+
210
+ export type SlateWebhookDeliveryAuthenticateHandler = (
211
+ context: SlateContext<{}, {}, { request: Request; deliveryConfig: unknown }>
212
+ ) => Promise<SlateWebhookDeliveryAuthentication>;
213
+
149
214
  export interface SlateTriggerGroupPollingParameters<
150
215
  ConfigType extends {},
151
216
  AuthType extends {}
@@ -168,6 +233,7 @@ export interface SlateTriggerGroupWebhookAutoRegistrationParameters<
168
233
  > {
169
234
  webhookTargetList: SlateWebhookTargetListHandler<ConfigType, AuthType>;
170
235
  webhookRegister: SlateWebhookRegisterHandler<ConfigType, AuthType>;
236
+ webhookRenew?: SlateWebhookRenewHandler<ConfigType, AuthType>;
171
237
  webhookUnregister: SlateWebhookUnregisterHandler<ConfigType, AuthType>;
172
238
  }
173
239
 
@@ -181,13 +247,28 @@ export interface SlateTriggerGroupWebhookManualRegistrationParameters<
181
247
  finish?: SlateWebhookManualFinishHandler<UserConfigSchema, FullConfigSchema>;
182
248
  }
183
249
 
250
+ /** A delivery setup a person completes once for an automatically registered group. */
251
+ export type SlateTriggerGroupWebhookDeliveryConfigParameters<
252
+ UserConfigSchema extends z.ZodType<any> = z.ZodType<any>,
253
+ FullConfigSchema extends z.ZodType<any> = z.ZodType<any>
254
+ > = SlateTriggerGroupWebhookManualRegistrationParameters<
255
+ UserConfigSchema,
256
+ FullConfigSchema
257
+ > & {
258
+ finish: SlateWebhookManualFinishHandler<UserConfigSchema, FullConfigSchema>;
259
+ authenticate: SlateWebhookDeliveryAuthenticateHandler;
260
+ };
261
+
184
262
  export interface SlateTriggerGroupWebhookParameters<
185
263
  ConfigType extends {},
186
264
  AuthType extends {}
187
265
  > {
188
266
  autoRegistration?: SlateTriggerGroupWebhookAutoRegistrationParameters<ConfigType, AuthType>;
189
267
  manualRegistration?: SlateTriggerGroupWebhookManualRegistrationParameters<any, any>;
268
+ deliveryConfig?: SlateTriggerGroupWebhookDeliveryConfigParameters<any, any>;
190
269
  process: SlateWebhookProcessHandler;
270
+ expandWebhook?: SlateWebhookExpandHandler<ConfigType, AuthType>;
271
+ reconcileIntervalSeconds?: number;
191
272
  }
192
273
 
193
274
  export interface SlateTriggerGroupCreateParameters<
@@ -198,6 +279,7 @@ export interface SlateTriggerGroupCreateParameters<
198
279
  description?: string;
199
280
  metadata?: Record<string, any>;
200
281
  eventSchema?: z.ZodType<InputType>;
282
+ selectionSchema?: z.ZodType<SlateTriggerGroupSelection>;
201
283
  }
202
284
 
203
285
  export interface SlateTriggerGroupParameters<
@@ -261,6 +343,10 @@ export class SlateTriggerGroup<
261
343
  return this._params.source;
262
344
  }
263
345
 
346
+ get selectionSchema() {
347
+ return this._params.selectionSchema;
348
+ }
349
+
264
350
  get polling() {
265
351
  return this._params.polling;
266
352
  }
@@ -327,7 +413,13 @@ export class SlateTriggerGroupBuilder<
327
413
  UserConfigSchema,
328
414
  FullConfigSchema
329
415
  >;
416
+ deliveryConfig?: SlateTriggerGroupWebhookDeliveryConfigParameters<
417
+ UserConfigSchema,
418
+ FullConfigSchema
419
+ >;
330
420
  process: SlateWebhookProcessHandler;
421
+ expandWebhook?: SlateWebhookExpandHandler<ConfigType, AuthType>;
422
+ reconcileIntervalSeconds?: number;
331
423
  }): SlateTriggerGroupBuilder<ConfigType, AuthType, InputType> {
332
424
  if (this.#source) {
333
425
  throw new SlateDeclarationError('Trigger group invocation is already defined');
@@ -339,11 +431,46 @@ export class SlateTriggerGroupBuilder<
339
431
  );
340
432
  }
341
433
 
434
+ if (props.deliveryConfig !== undefined) {
435
+ if (!props.autoRegistration) {
436
+ throw new SlateDeclarationError(
437
+ 'A webhook delivery config requires automatic webhook registration'
438
+ );
439
+ }
440
+ if (!props.deliveryConfig.finish) {
441
+ throw new SlateDeclarationError('A webhook delivery config requires a finish handler');
442
+ }
443
+ if (!props.deliveryConfig.authenticate) {
444
+ throw new SlateDeclarationError(
445
+ 'A webhook delivery config requires an authenticate handler'
446
+ );
447
+ }
448
+ }
449
+
450
+ if (props.reconcileIntervalSeconds !== undefined) {
451
+ if (
452
+ !Number.isSafeInteger(props.reconcileIntervalSeconds) ||
453
+ props.reconcileIntervalSeconds < 900
454
+ ) {
455
+ throw new SlateDeclarationError(
456
+ 'Webhook reconciliation interval must be an integer of at least 900 seconds'
457
+ );
458
+ }
459
+ if (!props.expandWebhook) {
460
+ throw new SlateDeclarationError(
461
+ 'Webhook reconciliation requires an expandWebhook handler'
462
+ );
463
+ }
464
+ }
465
+
342
466
  this.#source = 'webhook';
343
467
  this.#webhook = {
344
468
  autoRegistration: props.autoRegistration,
345
469
  manualRegistration: props.manualRegistration,
346
- process: props.process
470
+ deliveryConfig: props.deliveryConfig,
471
+ process: props.process,
472
+ expandWebhook: props.expandWebhook,
473
+ reconcileIntervalSeconds: props.reconcileIntervalSeconds
347
474
  };
348
475
 
349
476
  return this;
@@ -12,6 +12,20 @@ import { PQueue } from './pQueue';
12
12
 
13
13
  export type { SlateLiveInvocationInfo };
14
14
 
15
+ export interface SlateRuntimeInfo {
16
+ metorial?: {
17
+ apiUrl: string;
18
+ region?: string | null;
19
+ environment?: string | null;
20
+ };
21
+ }
22
+
23
+ let freezeRuntimeInfo = (runtimeInfo: SlateRuntimeInfo): Readonly<SlateRuntimeInfo> =>
24
+ Object.freeze({
25
+ ...runtimeInfo,
26
+ ...(runtimeInfo.metorial ? { metorial: Object.freeze({ ...runtimeInfo.metorial }) } : {})
27
+ });
28
+
15
29
  export type SlateAddAttachmentContent =
16
30
  | Buffer
17
31
  | Uint8Array
@@ -80,6 +94,7 @@ export class SlatePublicContext<InputType extends {}> {
80
94
  #httpTraces: SlateHttpTrace[] = [];
81
95
 
82
96
  #liveInvocation: SlateLiveInvocationInfo | null;
97
+ #runtimeInfo: Readonly<SlateRuntimeInfo> | null;
83
98
  #attachmentsDisabled = false;
84
99
  #attachments: SlateAttachment[] = [];
85
100
  #pendingUploads: Promise<void>[] = [];
@@ -96,10 +111,16 @@ export class SlatePublicContext<InputType extends {}> {
96
111
  input: InputType,
97
112
  private readonly spec: SlateSpecification<any, any>,
98
113
  private readonly logger: SlateLogger,
99
- liveInvocation: SlateLiveInvocationInfo | null = null
114
+ liveInvocation: SlateLiveInvocationInfo | null = null,
115
+ runtimeInfo: SlateRuntimeInfo | null = null
100
116
  ) {
101
117
  this.#input = input;
102
118
  this.#liveInvocation = liveInvocation;
119
+ this.#runtimeInfo = runtimeInfo && freezeRuntimeInfo(runtimeInfo);
120
+ }
121
+
122
+ get runtimeInfo() {
123
+ return this.#runtimeInfo;
103
124
  }
104
125
 
105
126
  get specification() {
@@ -294,9 +315,10 @@ export class SlateContext<
294
315
  auth: AuthType,
295
316
  spec: SlateSpecification<ConfigType, AuthType>,
296
317
  logger: SlateLogger,
297
- liveInvocation: SlateLiveInvocationInfo | null = null
318
+ liveInvocation: SlateLiveInvocationInfo | null = null,
319
+ runtimeInfo: SlateRuntimeInfo | null = null
298
320
  ) {
299
- super(input, spec, logger, liveInvocation);
321
+ super(input, spec, logger, liveInvocation, runtimeInfo);
300
322
  this.#config = config;
301
323
  this.#auth = auth;
302
324
  }
@@ -30,3 +30,5 @@ export let getCurrentContext = <
30
30
  }
31
31
  return context as SlateContext<ConfigType, AuthType, InputType>;
32
32
  };
33
+
34
+ export let getRuntimeInfo = () => asyncLocalStorage.getStore()?.runtimeInfo ?? null;
@@ -1,5 +1,21 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { createHmacSignature, verifyHmacSignature } from './hmac';
2
+ import { createHmacSignature, timingSafeStringEqual, verifyHmacSignature } from './hmac';
3
+
4
+ describe('timingSafeStringEqual', () => {
5
+ it('matches only the identical string', () => {
6
+ expect(timingSafeStringEqual('secret', 'secret')).toBe(true);
7
+ expect(timingSafeStringEqual('secret', 'secreT')).toBe(false);
8
+ expect(timingSafeStringEqual('secret', 'secret-longer')).toBe(false);
9
+ expect(timingSafeStringEqual('secret', '')).toBe(false);
10
+ });
11
+
12
+ it('rejects a missing value and never matches an empty expectation', () => {
13
+ expect(timingSafeStringEqual('secret', null)).toBe(false);
14
+ expect(timingSafeStringEqual('', undefined)).toBe(false);
15
+ expect(timingSafeStringEqual('', null)).toBe(false);
16
+ expect(timingSafeStringEqual('', '')).toBe(false);
17
+ });
18
+ });
3
19
 
4
20
  describe('createHmacSignature', () => {
5
21
  it('creates prefixed hexadecimal HMAC signatures', () => {
@@ -21,12 +21,17 @@ export let createHmacSignature = ({
21
21
  }: CreateHmacSignatureOptions) =>
22
22
  `${prefix}${createHmac(algorithm, secret).update(payload).digest(digest)}`;
23
23
 
24
- export let verifyHmacSignature = (options: VerifyHmacSignatureOptions) => {
25
- let expected = createHmacSignature(options);
24
+ /** Constant-time secret comparison; an empty expected secret matches nothing. */
25
+ export let timingSafeStringEqual = (expected: string, received: string | null | undefined) => {
26
26
  let expectedBytes = Buffer.from(expected);
27
- let signatureBytes = Buffer.from(options.signature);
28
- let lengthsMatch = signatureBytes.length === expectedBytes.length;
29
- let comparableSignature = lengthsMatch ? signatureBytes : Buffer.alloc(expectedBytes.length);
27
+ let receivedBytes = Buffer.from(received ?? '');
28
+ let lengthsMatch = receivedBytes.length === expectedBytes.length;
29
+ let comparable = lengthsMatch ? receivedBytes : Buffer.alloc(expectedBytes.length);
30
30
 
31
- return timingSafeEqual(comparableSignature, expectedBytes) && lengthsMatch;
31
+ return (
32
+ timingSafeEqual(comparable, expectedBytes) && lengthsMatch && expectedBytes.length > 0
33
+ );
32
34
  };
35
+
36
+ export let verifyHmacSignature = (options: VerifyHmacSignatureOptions) =>
37
+ timingSafeStringEqual(createHmacSignature(options), options.signature);
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { pruneSeenEntries, seenEntryKey, seenEntrySchema } from './index';
3
+
4
+ describe('seen entries', () => {
5
+ it('derives a short stable key', () => {
6
+ let key = seenEntryKey('["account","event",1]');
7
+ expect(key).toMatch(/^[A-Za-z0-9_-]{16}$/);
8
+ expect(seenEntryKey('["account","event",1]')).toBe(key);
9
+ expect(seenEntryKey('["account","event",2]')).not.toBe(key);
10
+ expect(seenEntrySchema.safeParse({ key, at: 1 }).success).toBe(true);
11
+ expect(seenEntrySchema.safeParse({ key, at: Number.NaN }).success).toBe(false);
12
+ });
13
+
14
+ it('drops entries before the cutoff unless retained, newest first', () => {
15
+ expect(
16
+ pruneSeenEntries(
17
+ [
18
+ { key: 'old', at: 1 },
19
+ { key: 'kept', at: 1, retain: true },
20
+ { key: 'b', at: 5 },
21
+ { key: 'a', at: 5 },
22
+ { key: 'new', at: 9 }
23
+ ],
24
+ { cutoff: 5, maxEntries: 10 }
25
+ )
26
+ ).toEqual([
27
+ { key: 'new', at: 9 },
28
+ { key: 'a', at: 5 },
29
+ { key: 'b', at: 5 },
30
+ { key: 'kept', at: 1 }
31
+ ]);
32
+ });
33
+
34
+ it('keeps the newest entries and reports the overflow', () => {
35
+ let onOverflow = vi.fn();
36
+ expect(
37
+ pruneSeenEntries(
38
+ [1, 2, 3, 4].map(at => ({ key: `k${at}`, at })),
39
+ { cutoff: 0, maxEntries: 2, onOverflow }
40
+ )
41
+ ).toEqual([
42
+ { key: 'k4', at: 4 },
43
+ { key: 'k3', at: 3 }
44
+ ]);
45
+ expect(onOverflow).toHaveBeenCalledWith(2);
46
+ });
47
+ });
@@ -0,0 +1,25 @@
1
+ import { createHash } from 'crypto';
2
+ import { z } from 'zod';
3
+
4
+ /** An event a poller already reported, saved so an overlapping read can skip it. */
5
+ export let seenEntrySchema = z.object({ key: z.string().min(1), at: z.number().finite() });
6
+ export type SeenEntry = z.infer<typeof seenEntrySchema>;
7
+
8
+ /** A short stable key for an idempotency key, so remembered events cost little state. */
9
+ export let seenEntryKey = (idempotencyKey: string) =>
10
+ createHash('sha256').update(idempotencyKey).digest('base64url').slice(0, 16);
11
+
12
+ /** Keeps entries at or after `cutoff` (or `retain`), newest first, at most `maxEntries`. */
13
+ export let pruneSeenEntries = (
14
+ entries: Iterable<SeenEntry & { retain?: boolean }>,
15
+ options: { cutoff: number; maxEntries: number; onOverflow?: (dropped: number) => void }
16
+ ): SeenEntry[] => {
17
+ let kept: SeenEntry[] = [];
18
+ for (let { key, at, retain } of entries) {
19
+ if (retain || at >= options.cutoff) kept.push({ key, at });
20
+ }
21
+ kept.sort((a, b) => b.at - a.at || (a.key < b.key ? -1 : 1));
22
+ if (kept.length <= options.maxEntries) return kept;
23
+ options.onOverflow?.(kept.length - options.maxEntries);
24
+ return kept.slice(0, options.maxEntries);
25
+ };
@@ -7,6 +7,7 @@ import {
7
7
  createApiServiceError,
8
8
  decodeApiErrorBody,
9
9
  getApiErrorResponseWithBody,
10
+ getUpstreamErrorStatus,
10
11
  looksLikeHtmlErrorBody,
11
12
  pushBoundedApiErrorDetail,
12
13
  readApiErrorText,
@@ -97,6 +98,27 @@ describe('API service error helpers', () => {
97
98
  );
98
99
  });
99
100
 
101
+ it('reads the upstream status from client errors and normalized ServiceErrors', () => {
102
+ expect(getUpstreamErrorStatus(axiosFailure(404, {}))).toBe(404);
103
+ expect(
104
+ getUpstreamErrorStatus(
105
+ new SlateError({ code: 'gone', message: 'Gone.', upstream: { status: 410 } })
106
+ )
107
+ ).toBe(410);
108
+ expect(
109
+ getUpstreamErrorStatus(createApiServiceError('Conflict', { upstreamStatus: 409 }))
110
+ ).toBe(409);
111
+ expect(
112
+ getUpstreamErrorStatus(createApiServiceError('Limited', { upstreamStatus: '429' }))
113
+ ).toBe(429);
114
+ expect(getUpstreamErrorStatus(createApiServiceError('No status'))).toBeUndefined();
115
+ expect(
116
+ getUpstreamErrorStatus(createApiServiceError('Bad', { upstreamStatus: 'ECONNRESET' }))
117
+ ).toBeUndefined();
118
+ expect(getUpstreamErrorStatus(new Error('ETIMEDOUT'))).toBeUndefined();
119
+ expect(getUpstreamErrorStatus(null)).toBeUndefined();
120
+ });
121
+
100
122
  it('allows provider-specific status and message extraction', () => {
101
123
  let error = buildApiServiceError(
102
124
  {
package/src/error/api.ts CHANGED
@@ -149,6 +149,14 @@ export let getApiErrorResponse = (error: unknown): ApiErrorResponse | undefined
149
149
  export let getApiErrorStatus = (error: unknown): ApiErrorStatus | undefined =>
150
150
  getApiErrorResponse(error)?.status;
151
151
 
152
+ /** Upstream HTTP status of an HTTP client error or a ServiceError with `upstreamStatus`. */
153
+ export let getUpstreamErrorStatus = (error: unknown): number | undefined => {
154
+ let value =
155
+ error instanceof ServiceError ? error.data.upstreamStatus : getApiErrorStatus(error);
156
+ let status = Number(value);
157
+ return Number.isInteger(status) && status >= 100 && status <= 599 ? status : undefined;
158
+ };
159
+
152
160
  // Like getApiErrorResponse, but also recovers the upstream response body from
153
161
  // interceptor-normalized SlateErrors. createAxios stores the body (sanitized by
154
162
  // sanitizeForBaggage) at SlateError.data.baggage.response, so provider-specific
@@ -2,5 +2,6 @@ export * from './api';
2
2
  export * from './axios';
3
3
  export * from './base';
4
4
  export * from './declaration';
5
+ export * from './parse';
5
6
  export * from './service';
6
7
  export * from './types';
@@ -0,0 +1,29 @@
1
+ import { ServiceError } from '@lowerdeck/error';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { z } from 'zod';
4
+ import { parseWithServiceError } from './parse';
5
+
6
+ describe('parseWithServiceError', () => {
7
+ let schema = z.object({ id: z.string().min(1), tags: z.array(z.string()).default([]) });
8
+
9
+ it('returns the parsed output, including defaults', () => {
10
+ expect(parseWithServiceError(schema, { id: 'a' }, 'Invalid value.')).toEqual({
11
+ id: 'a',
12
+ tags: []
13
+ });
14
+ });
15
+
16
+ it('throws a ServiceError with the message and reason', () => {
17
+ let error: unknown;
18
+ try {
19
+ parseWithServiceError(schema, { id: '' }, 'The saved value is invalid.', {
20
+ reason: 'saved_value_invalid'
21
+ });
22
+ } catch (caught) {
23
+ error = caught;
24
+ }
25
+ expect(error).toBeInstanceOf(ServiceError);
26
+ expect((error as ServiceError<any>).data.message).toBe('The saved value is invalid.');
27
+ expect((error as ServiceError<any>).data.reason).toBe('saved_value_invalid');
28
+ });
29
+ });
@@ -0,0 +1,14 @@
1
+ import type { z } from 'zod';
2
+ import { createApiServiceError } from './api';
3
+
4
+ /** Parses a saved or received value, throwing a user-facing `ServiceError` on mismatch. */
5
+ export let parseWithServiceError = <T extends z.ZodType>(
6
+ schema: T,
7
+ value: unknown,
8
+ message: string,
9
+ options: { reason?: string } = {}
10
+ ): z.output<T> => {
11
+ let result = schema.safeParse(value);
12
+ if (!result.success) throw createApiServiceError(message, options);
13
+ return result.data;
14
+ };
package/src/index.ts CHANGED
@@ -7,10 +7,12 @@ export * from './axios';
7
7
  export * from './config';
8
8
  export * from './context';
9
9
  export * from './crypto';
10
+ export * from './dedup';
10
11
  export * from './docs';
11
12
  export * from './error';
12
13
  export * from './file';
13
14
  export * from './logger';
14
15
  export * from './object';
16
+ export * from './pagination';
15
17
  export * from './specification';
16
18
  export * from './webhook';
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { assertNewPageToken, rememberPageToken } from './index';
3
+
4
+ describe('page token guards', () => {
5
+ it('remembers each token once', () => {
6
+ let seen = new Set<string>();
7
+ expect(rememberPageToken(seen, 'a')).toBe(true);
8
+ expect(rememberPageToken(seen, 'b')).toBe(true);
9
+ expect(rememberPageToken(seen, 'a')).toBe(false);
10
+ });
11
+
12
+ it('throws the caller error only for a repeated token', () => {
13
+ let seen = new Set<string>(['start']);
14
+ let repeated = () => new Error('repeated');
15
+ expect(() => assertNewPageToken(seen, undefined, repeated)).not.toThrow();
16
+ expect(() => assertNewPageToken(seen, '', repeated)).not.toThrow();
17
+ expect(() => assertNewPageToken(seen, 'next', repeated)).not.toThrow();
18
+ expect(() => assertNewPageToken(seen, 'next', repeated)).toThrow('repeated');
19
+ expect(() => assertNewPageToken(seen, 'start', repeated)).toThrow('repeated');
20
+ });
21
+ });
@@ -0,0 +1,15 @@
1
+ /** Records a page token in `seen`; `false` means it repeated and following it would loop. */
2
+ export let rememberPageToken = (seen: Set<string>, token: string) => {
3
+ if (seen.has(token)) return false;
4
+ seen.add(token);
5
+ return true;
6
+ };
7
+
8
+ /** Throws `repeated()` when a listing returns a page token it already returned. */
9
+ export let assertNewPageToken = (
10
+ seen: Set<string>,
11
+ token: string | null | undefined,
12
+ repeated: () => unknown
13
+ ) => {
14
+ if (token && !rememberPageToken(seen, token)) throw repeated();
15
+ };
@@ -37,12 +37,28 @@ let createManualWebhookTriggerGroup = (spec: ReturnType<typeof createTestSpec>,
37
37
  .routingMatchers(async () => [])
38
38
  .build();
39
39
 
40
- let createAutoWebhookTriggerGroup = (spec: ReturnType<typeof createTestSpec>, key: string) =>
40
+ let createAutoWebhookTriggerGroup = (
41
+ spec: ReturnType<typeof createTestSpec>,
42
+ key: string,
43
+ options: { deliveryConfig?: boolean } = {}
44
+ ) =>
41
45
  SlateTriggerGroup.create(spec, {
42
46
  key,
43
47
  name: key
44
48
  })
45
49
  .webhook({
50
+ deliveryConfig: options.deliveryConfig
51
+ ? {
52
+ userConfigSchema: z.object({}),
53
+ fullConfigSchema: z.object({}),
54
+ setup: async () => ({
55
+ webhookSetupDocument: '',
56
+ partialWebhookRegistrationPayload: {}
57
+ }),
58
+ finish: async () => ({ webhookRegistrationPayload: {} }),
59
+ authenticate: async () => ({ routingKeys: [] })
60
+ }
61
+ : undefined,
46
62
  autoRegistration: {
47
63
  webhookTargetList: async () => ({ targets: [], nextPageToken: null }),
48
64
  webhookRegister: async () => ({
@@ -70,10 +86,45 @@ describe('Slate.create', () => {
70
86
  triggerGroups: [first, second]
71
87
  })
72
88
  ).toThrow(
73
- 'Only one trigger group may use manual webhook registration, but found: manual_one, manual_two'
89
+ 'Only one trigger group may use manual webhook registration or a delivery config, but found: manual_one, manual_two'
74
90
  );
75
91
  });
76
92
 
93
+ it('rejects more than one trigger group with a manual registration or delivery config', () => {
94
+ let spec = createTestSpec();
95
+ let manual = createManualWebhookTriggerGroup(spec, 'manual_group');
96
+ let first = createAutoWebhookTriggerGroup(spec, 'config_one', { deliveryConfig: true });
97
+ let second = createAutoWebhookTriggerGroup(spec, 'config_two', { deliveryConfig: true });
98
+ let create = (triggerGroups: (typeof manual)[]) =>
99
+ Slate.create({ spec, tools: [], triggers: [], triggerGroups });
100
+
101
+ expect(() => create([first, second])).toThrow(
102
+ 'Only one trigger group may use manual webhook registration or a delivery config, but found: config_one, config_two'
103
+ );
104
+ expect(() => create([manual, first])).toThrow('but found: manual_group, config_one');
105
+ });
106
+
107
+ it('allows a single delivery config alongside plain automatic trigger groups', () => {
108
+ let spec = createTestSpec();
109
+ let configured = createAutoWebhookTriggerGroup(spec, 'config_group', {
110
+ deliveryConfig: true
111
+ });
112
+ let auto = createAutoWebhookTriggerGroup(spec, 'auto_group');
113
+
114
+ let slate = Slate.create({
115
+ spec,
116
+ tools: [],
117
+ triggers: [],
118
+ triggerGroups: [auto, configured, createAutoWebhookTriggerGroup(spec, 'other_auto')]
119
+ });
120
+
121
+ expect(slate.triggerGroups.map(group => group.key)).toEqual([
122
+ 'auto_group',
123
+ 'config_group',
124
+ 'other_auto'
125
+ ]);
126
+ });
127
+
77
128
  it('allows a single manual webhook trigger group alongside other trigger groups', () => {
78
129
  let spec = createTestSpec();
79
130
  let manual = createManualWebhookTriggerGroup(spec, 'manual_group');
@@ -63,12 +63,14 @@ export class Slate<ConfigType extends {}, AuthType extends {}> {
63
63
  seenTriggerGroupKeys.add(group.key);
64
64
  }
65
65
 
66
- let manualWebhookTriggerGroups = triggerGroups.filter(
67
- group => group.source === 'webhook' && group.webhook?.manualRegistration
66
+ let configuredWebhookTriggerGroups = triggerGroups.filter(
67
+ group =>
68
+ group.source === 'webhook' &&
69
+ (group.webhook?.manualRegistration || group.webhook?.deliveryConfig)
68
70
  );
69
- if (manualWebhookTriggerGroups.length > 1) {
71
+ if (configuredWebhookTriggerGroups.length > 1) {
70
72
  throw new SlateDeclarationError(
71
- `Only one trigger group may use manual webhook registration, but found: ${manualWebhookTriggerGroups
73
+ `Only one trigger group may use manual webhook registration or a delivery config, but found: ${configuredWebhookTriggerGroups
72
74
  .map(group => group.key)
73
75
  .join(', ')}`
74
76
  );