@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.
- package/dist/index.cjs +289 -19
- package/dist/index.d.cts +168 -2
- package/dist/index.d.ts +168 -2
- package/dist/index.module.js +279 -19
- package/package.json +1 -1
- package/src/action/action.ts +1 -0
- package/src/action/builder.ts +73 -1
- package/src/action/index.ts +1 -0
- package/src/action/optionList.ts +94 -0
- package/src/action/trigger.test.ts +132 -0
- package/src/action/trigger.ts +7 -0
- package/src/action/triggerGroup.test.ts +172 -0
- package/src/action/triggerGroup.ts +248 -5
- package/src/crypto/hmac.test.ts +17 -1
- package/src/crypto/hmac.ts +11 -6
- package/src/dedup/index.test.ts +47 -0
- package/src/dedup/index.ts +25 -0
- package/src/error/api.test.ts +22 -0
- package/src/error/api.ts +8 -0
- package/src/error/index.ts +1 -0
- package/src/error/parse.test.ts +29 -0
- package/src/error/parse.ts +14 -0
- package/src/index.ts +2 -0
- package/src/pagination/index.test.ts +21 -0
- package/src/pagination/index.ts +15 -0
- package/src/specification/slate.test.ts +123 -3
- package/src/specification/slate.ts +33 -7
|
@@ -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<
|
|
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<
|
|
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
|
-
|
|
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
|
}
|
package/src/crypto/hmac.test.ts
CHANGED
|
@@ -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', () => {
|
package/src/crypto/hmac.ts
CHANGED
|
@@ -21,12 +21,17 @@ export let createHmacSignature = ({
|
|
|
21
21
|
}: CreateHmacSignatureOptions) =>
|
|
22
22
|
`${prefix}${createHmac(algorithm, secret).update(payload).digest(digest)}`;
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
28
|
-
let lengthsMatch =
|
|
29
|
-
let
|
|
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
|
|
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
|
+
};
|
package/src/error/api.test.ts
CHANGED
|
@@ -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
|
package/src/error/index.ts
CHANGED
|
@@ -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
|
+
};
|