@slates/provider 1.0.0-rc.33 → 1.0.0-rc.35

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.
@@ -3,11 +3,14 @@ import type { SlateContext } from '../context';
3
3
  import { SlateDeclarationError } from '../error';
4
4
  import type { SlateSpecification } from '../specification/specification';
5
5
  import type { SlateWebhookHttpResponseInit } from './action';
6
+ import type { SlateOptionListPage } from './optionList';
6
7
 
7
8
  export let SlateDefaultPollingIntervalSeconds = 60 * 10;
8
9
 
9
10
  export type SlateTriggerRoutingMatcher = Record<string, any>;
10
11
 
12
+ export type SlateTriggerGroupSelection = Record<string, any>;
13
+
11
14
  export type SlateWebhookTargetOwnership = 'single_user' | 'multi_user';
12
15
 
13
16
  export interface SlateWebhookTarget {
@@ -24,8 +27,86 @@ export type SlateTriggerGroupRoutingMatchersHandler<
24
27
  AuthType extends {}
25
28
  > = (context: SlateContext<ConfigType, AuthType, {}>) => Promise<SlateTriggerRoutingMatcher[]>;
26
29
 
30
+ /** Lists the values an account can pick for one selection field. `selection` holds what is entered so far and may be incomplete. */
31
+ export type SlateTriggerGroupSelectionOptionsHandler<
32
+ ConfigType extends {},
33
+ AuthType extends {}
34
+ > = (
35
+ context: SlateContext<
36
+ ConfigType,
37
+ AuthType,
38
+ { query: string | null; pageToken: any | null; selection: SlateTriggerGroupSelection }
39
+ >
40
+ ) => Promise<SlateOptionListPage>;
41
+
42
+ /** An option list exported by another integration, run with that integration's connection for the same account. */
43
+ export interface SlateLinkedOptionList {
44
+ /** Registry name of the integration, as `<scope>/<name>`. */
45
+ slate: string;
46
+ optionList: string;
47
+ params?: Record<string, any>;
48
+ }
49
+
50
+ export interface SlateTriggerGroupSelectionOptionsSources<
51
+ ConfigType extends {},
52
+ AuthType extends {}
53
+ > {
54
+ list?: SlateTriggerGroupSelectionOptionsHandler<ConfigType, AuthType>;
55
+ linked?: SlateLinkedOptionList;
56
+ }
57
+
58
+ let LINKED_SLATE_PATTERN = /^[^/\s]+\/[^/\s]+$/;
59
+
60
+ let checkSelectionOptions = <ConfigType extends {}, AuthType extends {}>(
61
+ field: string,
62
+ sources: SlateTriggerGroupSelectionOptionsSources<ConfigType, AuthType>,
63
+ shape: Record<string, unknown>
64
+ ): SlateTriggerGroupSelectionOptionsSources<ConfigType, AuthType> => {
65
+ if (!Object.hasOwn(shape, field)) {
66
+ throw new SlateDeclarationError(
67
+ `Selection field "${field}" is not a top-level property of the selection schema`
68
+ );
69
+ }
70
+ if (typeof sources !== 'object' || sources === null) {
71
+ throw new SlateDeclarationError(
72
+ `Selection field "${field}" must be declared as { list, linked }`
73
+ );
74
+ }
75
+
76
+ let { list, linked } = sources;
77
+ if (!list && !linked) {
78
+ throw new SlateDeclarationError(
79
+ `Selection field "${field}" needs its own options list, a linked option list, or both`
80
+ );
81
+ }
82
+ if (list !== undefined && typeof list !== 'function') {
83
+ throw new SlateDeclarationError(`Selection field "${field}" list must be a function`);
84
+ }
85
+ if (linked) {
86
+ if (typeof linked.slate !== 'string' || !LINKED_SLATE_PATTERN.test(linked.slate)) {
87
+ throw new SlateDeclarationError(
88
+ `Selection field "${field}" links to slate "${linked.slate}", which must be written as <scope>/<name>`
89
+ );
90
+ }
91
+ if (typeof linked.optionList !== 'string' || !linked.optionList.trim()) {
92
+ throw new SlateDeclarationError(
93
+ `Selection field "${field}" must name the option list it links to`
94
+ );
95
+ }
96
+ }
97
+
98
+ return {
99
+ ...(list ? { list } : {}),
100
+ ...(linked ? { linked } : {})
101
+ };
102
+ };
103
+
27
104
  export type SlateTriggerGroupPollHandler<ConfigType extends {}, AuthType extends {}> = (
28
- context: SlateContext<ConfigType, AuthType, { state: any | null }>
105
+ context: SlateContext<
106
+ ConfigType,
107
+ AuthType,
108
+ { state: any | null; selection?: SlateTriggerGroupSelection }
109
+ >
29
110
  ) => Promise<{
30
111
  events: { payload: any; idempotencyKey?: string }[];
31
112
  updatedState?: any;
@@ -65,7 +146,11 @@ export type SlateTriggerGroupGatewayReceiveHandler<
65
146
  }>;
66
147
 
67
148
  export type SlateWebhookTargetListHandler<ConfigType extends {}, AuthType extends {}> = (
68
- context: SlateContext<ConfigType, AuthType, { pageToken: any | null }>
149
+ context: SlateContext<
150
+ ConfigType,
151
+ AuthType,
152
+ { pageToken: any | null; selection?: SlateTriggerGroupSelection }
153
+ >
69
154
  ) => Promise<{
70
155
  targets: SlateWebhookTarget[];
71
156
  nextPageToken: any | null;
@@ -81,11 +166,54 @@ export type SlateWebhookRegisterHandler<ConfigType extends {}, AuthType extends
81
166
  webhookTargetIdentifier: string;
82
167
  webhookTargetPayload: any;
83
168
  webhookUrl: string;
169
+ operationId?: string;
170
+ selection?: SlateTriggerGroupSelection;
171
+ deliveryConfig?: unknown;
84
172
  }
85
173
  >
86
- ) => Promise<{
174
+ ) => Promise<SlateWebhookRegistrationResult>;
175
+
176
+ export interface SlateWebhookRegistrationResult {
87
177
  webhookRegistrationIdentifier: string;
88
178
  webhookRegistrationPayload: any;
179
+ expiresAt?: string | null;
180
+ renewAfter?: string | null;
181
+ /** Identities of this registration on a delivery channel shared with other registrations. */
182
+ routingKeys?: string[];
183
+ }
184
+
185
+ export type SlateWebhookRenewHandler<ConfigType extends {}, AuthType extends {}> = (
186
+ context: SlateContext<
187
+ ConfigType,
188
+ AuthType,
189
+ {
190
+ webhookRegistrationIdentifier: string;
191
+ webhookRegistrationPayload: any;
192
+ webhookUrl: string;
193
+ webhookTargetIdentifier: string;
194
+ webhookTargetPayload: any;
195
+ operationId: string;
196
+ selection?: SlateTriggerGroupSelection;
197
+ deliveryConfig?: unknown;
198
+ }
199
+ >
200
+ ) => Promise<SlateWebhookRegistrationResult>;
201
+
202
+ export type SlateWebhookExpandHandler<ConfigType extends {}, AuthType extends {}> = (
203
+ context: SlateContext<
204
+ ConfigType,
205
+ AuthType,
206
+ {
207
+ payload: unknown | null;
208
+ state: any | null;
209
+ selection?: SlateTriggerGroupSelection;
210
+ deliveryId: string;
211
+ webhookRegistrationPayload: any;
212
+ }
213
+ >
214
+ ) => Promise<{
215
+ events: { payload: any; idempotencyKey?: string }[];
216
+ updatedState?: any;
89
217
  }>;
90
218
 
91
219
  export type SlateWebhookUnregisterHandler<ConfigType extends {}, AuthType extends {}> = (
@@ -146,6 +274,18 @@ export interface SlateWebhookSkipped {
146
274
  reason: string;
147
275
  }
148
276
 
277
+ /**
278
+ * Routing keys name the registrations a request addresses; an empty list acknowledges it
279
+ * without handing it to any. A rejection answers the sender with its status.
280
+ */
281
+ export type SlateWebhookDeliveryAuthentication =
282
+ | { routingKeys: string[]; rejected?: undefined }
283
+ | { rejected: { status: number }; routingKeys?: undefined };
284
+
285
+ export type SlateWebhookDeliveryAuthenticateHandler = (
286
+ context: SlateContext<{}, {}, { request: Request; deliveryConfig: unknown }>
287
+ ) => Promise<SlateWebhookDeliveryAuthentication>;
288
+
149
289
  export interface SlateTriggerGroupPollingParameters<
150
290
  ConfigType extends {},
151
291
  AuthType extends {}
@@ -168,6 +308,7 @@ export interface SlateTriggerGroupWebhookAutoRegistrationParameters<
168
308
  > {
169
309
  webhookTargetList: SlateWebhookTargetListHandler<ConfigType, AuthType>;
170
310
  webhookRegister: SlateWebhookRegisterHandler<ConfigType, AuthType>;
311
+ webhookRenew?: SlateWebhookRenewHandler<ConfigType, AuthType>;
171
312
  webhookUnregister: SlateWebhookUnregisterHandler<ConfigType, AuthType>;
172
313
  }
173
314
 
@@ -181,13 +322,28 @@ export interface SlateTriggerGroupWebhookManualRegistrationParameters<
181
322
  finish?: SlateWebhookManualFinishHandler<UserConfigSchema, FullConfigSchema>;
182
323
  }
183
324
 
325
+ /** A delivery setup a person completes once for an automatically registered group. */
326
+ export type SlateTriggerGroupWebhookDeliveryConfigParameters<
327
+ UserConfigSchema extends z.ZodType<any> = z.ZodType<any>,
328
+ FullConfigSchema extends z.ZodType<any> = z.ZodType<any>
329
+ > = SlateTriggerGroupWebhookManualRegistrationParameters<
330
+ UserConfigSchema,
331
+ FullConfigSchema
332
+ > & {
333
+ finish: SlateWebhookManualFinishHandler<UserConfigSchema, FullConfigSchema>;
334
+ authenticate: SlateWebhookDeliveryAuthenticateHandler;
335
+ };
336
+
184
337
  export interface SlateTriggerGroupWebhookParameters<
185
338
  ConfigType extends {},
186
339
  AuthType extends {}
187
340
  > {
188
341
  autoRegistration?: SlateTriggerGroupWebhookAutoRegistrationParameters<ConfigType, AuthType>;
189
342
  manualRegistration?: SlateTriggerGroupWebhookManualRegistrationParameters<any, any>;
343
+ deliveryConfig?: SlateTriggerGroupWebhookDeliveryConfigParameters<any, any>;
190
344
  process: SlateWebhookProcessHandler;
345
+ expandWebhook?: SlateWebhookExpandHandler<ConfigType, AuthType>;
346
+ reconcileIntervalSeconds?: number;
191
347
  }
192
348
 
193
349
  export interface SlateTriggerGroupCreateParameters<
@@ -198,6 +354,7 @@ export interface SlateTriggerGroupCreateParameters<
198
354
  description?: string;
199
355
  metadata?: Record<string, any>;
200
356
  eventSchema?: z.ZodType<InputType>;
357
+ selectionSchema?: z.ZodType<SlateTriggerGroupSelection>;
201
358
  }
202
359
 
203
360
  export interface SlateTriggerGroupParameters<
@@ -210,6 +367,10 @@ export interface SlateTriggerGroupParameters<
210
367
  webhook?: SlateTriggerGroupWebhookParameters<ConfigType, AuthType>;
211
368
  gateway?: SlateTriggerGroupGatewayParameters<ConfigType, AuthType>;
212
369
  routingMatchers: SlateTriggerGroupRoutingMatchersHandler<ConfigType, AuthType>;
370
+ selectionOptions?: Record<
371
+ string,
372
+ SlateTriggerGroupSelectionOptionsSources<ConfigType, AuthType>
373
+ >;
213
374
  }
214
375
 
215
376
  export class SlateTriggerGroup<
@@ -261,6 +422,10 @@ export class SlateTriggerGroup<
261
422
  return this._params.source;
262
423
  }
263
424
 
425
+ get selectionSchema() {
426
+ return this._params.selectionSchema;
427
+ }
428
+
264
429
  get polling() {
265
430
  return this._params.polling;
266
431
  }
@@ -276,6 +441,10 @@ export class SlateTriggerGroup<
276
441
  get routingMatchers() {
277
442
  return this._params.routingMatchers;
278
443
  }
444
+
445
+ get selectionOptions() {
446
+ return this._params.selectionOptions ?? {};
447
+ }
279
448
  }
280
449
 
281
450
  export class SlateTriggerGroupBuilder<
@@ -289,6 +458,10 @@ export class SlateTriggerGroupBuilder<
289
458
  #gateway: SlateTriggerGroupGatewayParameters<ConfigType, AuthType> | null = null;
290
459
  #routingMatchers: SlateTriggerGroupRoutingMatchersHandler<ConfigType, AuthType> | null =
291
460
  null;
461
+ #selectionOptions: Record<
462
+ string,
463
+ SlateTriggerGroupSelectionOptionsSources<ConfigType, AuthType>
464
+ > = {};
292
465
 
293
466
  constructor(
294
467
  private readonly spec: SlateSpecification<ConfigType, AuthType>,
@@ -327,7 +500,13 @@ export class SlateTriggerGroupBuilder<
327
500
  UserConfigSchema,
328
501
  FullConfigSchema
329
502
  >;
503
+ deliveryConfig?: SlateTriggerGroupWebhookDeliveryConfigParameters<
504
+ UserConfigSchema,
505
+ FullConfigSchema
506
+ >;
330
507
  process: SlateWebhookProcessHandler;
508
+ expandWebhook?: SlateWebhookExpandHandler<ConfigType, AuthType>;
509
+ reconcileIntervalSeconds?: number;
331
510
  }): SlateTriggerGroupBuilder<ConfigType, AuthType, InputType> {
332
511
  if (this.#source) {
333
512
  throw new SlateDeclarationError('Trigger group invocation is already defined');
@@ -339,11 +518,46 @@ export class SlateTriggerGroupBuilder<
339
518
  );
340
519
  }
341
520
 
521
+ if (props.deliveryConfig !== undefined) {
522
+ if (!props.autoRegistration) {
523
+ throw new SlateDeclarationError(
524
+ 'A webhook delivery config requires automatic webhook registration'
525
+ );
526
+ }
527
+ if (!props.deliveryConfig.finish) {
528
+ throw new SlateDeclarationError('A webhook delivery config requires a finish handler');
529
+ }
530
+ if (!props.deliveryConfig.authenticate) {
531
+ throw new SlateDeclarationError(
532
+ 'A webhook delivery config requires an authenticate handler'
533
+ );
534
+ }
535
+ }
536
+
537
+ if (props.reconcileIntervalSeconds !== undefined) {
538
+ if (
539
+ !Number.isSafeInteger(props.reconcileIntervalSeconds) ||
540
+ props.reconcileIntervalSeconds < 900
541
+ ) {
542
+ throw new SlateDeclarationError(
543
+ 'Webhook reconciliation interval must be an integer of at least 900 seconds'
544
+ );
545
+ }
546
+ if (!props.expandWebhook) {
547
+ throw new SlateDeclarationError(
548
+ 'Webhook reconciliation requires an expandWebhook handler'
549
+ );
550
+ }
551
+ }
552
+
342
553
  this.#source = 'webhook';
343
554
  this.#webhook = {
344
555
  autoRegistration: props.autoRegistration,
345
556
  manualRegistration: props.manualRegistration,
346
- process: props.process
557
+ deliveryConfig: props.deliveryConfig,
558
+ process: props.process,
559
+ expandWebhook: props.expandWebhook,
560
+ reconcileIntervalSeconds: props.reconcileIntervalSeconds
347
561
  };
348
562
 
349
563
  return this;
@@ -370,6 +584,34 @@ export class SlateTriggerGroupBuilder<
370
584
  return this;
371
585
  }
372
586
 
587
+ /**
588
+ * Lets the dashboard offer a list for these selection fields instead of a typed-in value.
589
+ * Each field must be a top-level property of the selection schema, which must be a zod object.
590
+ * For an array property the listed options are values for its items.
591
+ */
592
+ selectionOptions(
593
+ declarations: Record<
594
+ string,
595
+ SlateTriggerGroupSelectionOptionsSources<ConfigType, AuthType>
596
+ >
597
+ ): SlateTriggerGroupBuilder<ConfigType, AuthType, InputType> {
598
+ let schema = this.params.selectionSchema;
599
+ if (!schema) {
600
+ throw new SlateDeclarationError('Selection options require a selection schema');
601
+ }
602
+ if (schema._zod?.def?.type !== 'object') {
603
+ throw new SlateDeclarationError(
604
+ 'Selection options require the selection schema to be a zod object'
605
+ );
606
+ }
607
+
608
+ let shape = (schema as z.ZodObject<any>).shape;
609
+ for (let [field, sources] of Object.entries(declarations)) {
610
+ this.#selectionOptions[field] = checkSelectionOptions(field, sources, shape);
611
+ }
612
+ return this;
613
+ }
614
+
373
615
  build(): SlateTriggerGroup<ConfigType, AuthType, InputType> {
374
616
  if (!this.#source) {
375
617
  throw new SlateDeclarationError(
@@ -386,7 +628,8 @@ export class SlateTriggerGroupBuilder<
386
628
  polling: this.#polling ?? undefined,
387
629
  webhook: this.#webhook ?? undefined,
388
630
  gateway: this.#gateway ?? undefined,
389
- routingMatchers: this.#routingMatchers
631
+ routingMatchers: this.#routingMatchers,
632
+ selectionOptions: this.#selectionOptions
390
633
  });
391
634
  }
392
635
  }
@@ -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
+ };