@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.
- package/dist/index.cjs +115 -16
- package/dist/index.d.cts +109 -4
- package/dist/index.d.ts +109 -4
- package/dist/index.module.js +106 -16
- package/package.json +1 -1
- package/src/action/triggerGroup.test.ts +90 -0
- package/src/action/triggerGroup.ts +131 -4
- package/src/context/context.ts +25 -3
- package/src/context/hook.ts +2 -0
- 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 +53 -2
- package/src/specification/slate.ts +6 -4
|
@@ -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<
|
|
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<
|
|
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
|
-
|
|
470
|
+
deliveryConfig: props.deliveryConfig,
|
|
471
|
+
process: props.process,
|
|
472
|
+
expandWebhook: props.expandWebhook,
|
|
473
|
+
reconcileIntervalSeconds: props.reconcileIntervalSeconds
|
|
347
474
|
};
|
|
348
475
|
|
|
349
476
|
return this;
|
package/src/context/context.ts
CHANGED
|
@@ -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
|
}
|
package/src/context/hook.ts
CHANGED
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
|
+
};
|
|
@@ -37,12 +37,28 @@ let createManualWebhookTriggerGroup = (spec: ReturnType<typeof createTestSpec>,
|
|
|
37
37
|
.routingMatchers(async () => [])
|
|
38
38
|
.build();
|
|
39
39
|
|
|
40
|
-
let createAutoWebhookTriggerGroup = (
|
|
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
|
|
67
|
-
group =>
|
|
66
|
+
let configuredWebhookTriggerGroups = triggerGroups.filter(
|
|
67
|
+
group =>
|
|
68
|
+
group.source === 'webhook' &&
|
|
69
|
+
(group.webhook?.manualRegistration || group.webhook?.deliveryConfig)
|
|
68
70
|
);
|
|
69
|
-
if (
|
|
71
|
+
if (configuredWebhookTriggerGroups.length > 1) {
|
|
70
72
|
throw new SlateDeclarationError(
|
|
71
|
-
`Only one trigger group may use manual webhook registration, but found: ${
|
|
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
|
);
|