@slates/provider 1.0.0-rc.16 → 1.0.0-rc.17
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 +396 -5
- package/dist/index.d.cts +183 -1
- package/dist/index.d.ts +183 -1
- package/dist/index.module.js +370 -2
- package/package.json +1 -1
- package/src/action/action.ts +45 -1
- package/src/action/builder.ts +5 -2
- package/src/action/trigger.ts +9 -1
- package/src/auth/index.ts +1 -0
- package/src/auth/oauth.test.ts +83 -0
- package/src/auth/oauth.ts +150 -0
- package/src/auth/types.ts +22 -6
- package/src/axios/http.test.ts +83 -0
- package/src/axios/index.ts +59 -1
- package/src/context/context.ts +8 -0
- package/src/crypto/hmac.test.ts +40 -0
- package/src/crypto/hmac.ts +32 -0
- package/src/crypto/index.ts +1 -0
- package/src/error/api.test.ts +114 -0
- package/src/error/api.ts +236 -0
- package/src/error/index.ts +1 -0
- package/src/file/index.test.ts +36 -0
- package/src/file/index.ts +27 -0
- package/src/index.ts +4 -0
- package/src/object/index.test.ts +38 -0
- package/src/object/index.ts +12 -0
- package/src/webhook/graph.test.ts +37 -0
- package/src/webhook/graph.ts +22 -0
- package/src/webhook/index.ts +2 -0
- package/src/webhook/meta.test.ts +52 -0
- package/src/webhook/meta.ts +43 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createApiServiceError } from '../error/api';
|
|
2
|
+
|
|
3
|
+
export type OAuthTokenResponse = {
|
|
4
|
+
access_token?: unknown;
|
|
5
|
+
refresh_token?: unknown;
|
|
6
|
+
expires_in?: unknown;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type OAuthRefreshTokenFallbackMode = 'nullish' | 'falsy';
|
|
11
|
+
|
|
12
|
+
export type OAuthExpiresAtOptions = {
|
|
13
|
+
nowMs?: number;
|
|
14
|
+
providerLabel?: string;
|
|
15
|
+
operation?: string;
|
|
16
|
+
required?: boolean;
|
|
17
|
+
expiresInType?: 'number' | 'numberLike';
|
|
18
|
+
message?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type NormalizeOAuthTokenResponseOptions = OAuthExpiresAtOptions & {
|
|
22
|
+
previousRefreshToken?: string;
|
|
23
|
+
refreshTokenFallbackMode?: OAuthRefreshTokenFallbackMode;
|
|
24
|
+
accessTokenMessage?: string;
|
|
25
|
+
expiresInMessage?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let isOAuthTokenResponse = (value: unknown): value is OAuthTokenResponse =>
|
|
29
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
|
+
|
|
31
|
+
let formatOAuthTokenResponseMessage = (
|
|
32
|
+
fieldLabel: string,
|
|
33
|
+
providerLabel = 'OAuth provider',
|
|
34
|
+
operation = 'token response'
|
|
35
|
+
) => `${providerLabel} OAuth ${operation} did not return ${fieldLabel}.`;
|
|
36
|
+
|
|
37
|
+
export let getOAuthExpiresAtFromExpiresIn = (
|
|
38
|
+
expiresIn: unknown,
|
|
39
|
+
options: OAuthExpiresAtOptions = {}
|
|
40
|
+
) => {
|
|
41
|
+
let {
|
|
42
|
+
nowMs = Date.now(),
|
|
43
|
+
providerLabel,
|
|
44
|
+
operation,
|
|
45
|
+
required = false,
|
|
46
|
+
expiresInType = 'numberLike',
|
|
47
|
+
message
|
|
48
|
+
} = options;
|
|
49
|
+
|
|
50
|
+
if (expiresIn === undefined || expiresIn === null || expiresIn === '') {
|
|
51
|
+
if (required) {
|
|
52
|
+
throw createApiServiceError(
|
|
53
|
+
message ??
|
|
54
|
+
formatOAuthTokenResponseMessage(
|
|
55
|
+
'a valid expires_in value',
|
|
56
|
+
providerLabel,
|
|
57
|
+
operation
|
|
58
|
+
),
|
|
59
|
+
{ reason: 'oauth_token_response' }
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let seconds =
|
|
67
|
+
typeof expiresIn === 'number'
|
|
68
|
+
? expiresIn
|
|
69
|
+
: expiresInType === 'numberLike' && typeof expiresIn === 'string'
|
|
70
|
+
? Number(expiresIn)
|
|
71
|
+
: Number.NaN;
|
|
72
|
+
|
|
73
|
+
if (!Number.isFinite(seconds)) {
|
|
74
|
+
throw createApiServiceError(
|
|
75
|
+
message ??
|
|
76
|
+
formatOAuthTokenResponseMessage('a valid expires_in value', providerLabel, operation),
|
|
77
|
+
{ reason: 'oauth_token_response' }
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return new Date(nowMs + seconds * 1000).toISOString();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
let getRefreshToken = (
|
|
85
|
+
refreshToken: unknown,
|
|
86
|
+
previousRefreshToken: string | undefined,
|
|
87
|
+
fallbackMode: OAuthRefreshTokenFallbackMode
|
|
88
|
+
) => {
|
|
89
|
+
if (typeof refreshToken === 'string') {
|
|
90
|
+
if (fallbackMode === 'falsy' && !refreshToken) {
|
|
91
|
+
return previousRefreshToken;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return refreshToken;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (fallbackMode === 'falsy' && !refreshToken) {
|
|
98
|
+
return previousRefreshToken;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return refreshToken === undefined || refreshToken === null
|
|
102
|
+
? previousRefreshToken
|
|
103
|
+
: undefined;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export let normalizeOAuthTokenResponse = (
|
|
107
|
+
data: unknown,
|
|
108
|
+
options: NormalizeOAuthTokenResponseOptions = {}
|
|
109
|
+
) => {
|
|
110
|
+
let {
|
|
111
|
+
providerLabel,
|
|
112
|
+
operation,
|
|
113
|
+
previousRefreshToken,
|
|
114
|
+
refreshTokenFallbackMode = 'nullish',
|
|
115
|
+
accessTokenMessage,
|
|
116
|
+
expiresInMessage,
|
|
117
|
+
...expiresAtOptions
|
|
118
|
+
} = options;
|
|
119
|
+
|
|
120
|
+
if (!isOAuthTokenResponse(data)) {
|
|
121
|
+
throw createApiServiceError(
|
|
122
|
+
accessTokenMessage ??
|
|
123
|
+
formatOAuthTokenResponseMessage('a token response object', providerLabel, operation),
|
|
124
|
+
{ reason: 'oauth_token_response' }
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (typeof data.access_token !== 'string' || !data.access_token) {
|
|
129
|
+
throw createApiServiceError(
|
|
130
|
+
accessTokenMessage ??
|
|
131
|
+
formatOAuthTokenResponseMessage('an access token', providerLabel, operation),
|
|
132
|
+
{ reason: 'oauth_token_response' }
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
token: data.access_token,
|
|
138
|
+
refreshToken: getRefreshToken(
|
|
139
|
+
data.refresh_token,
|
|
140
|
+
previousRefreshToken,
|
|
141
|
+
refreshTokenFallbackMode
|
|
142
|
+
),
|
|
143
|
+
expiresAt: getOAuthExpiresAtFromExpiresIn(data.expires_in, {
|
|
144
|
+
...expiresAtOptions,
|
|
145
|
+
providerLabel,
|
|
146
|
+
operation,
|
|
147
|
+
message: expiresInMessage
|
|
148
|
+
})
|
|
149
|
+
};
|
|
150
|
+
};
|
package/src/auth/types.ts
CHANGED
|
@@ -97,7 +97,7 @@ export type SlateAuthWithToken<InputType extends {}, OutputType extends { token?
|
|
|
97
97
|
|
|
98
98
|
docs?: SlateAuthDocsReference[];
|
|
99
99
|
|
|
100
|
-
getOutput: (ctx: { input: InputType }) => Promise<{
|
|
100
|
+
getOutput: (ctx: { input: InputType; config?: SlateAuthCallbackConfig }) => Promise<{
|
|
101
101
|
output: OutputType;
|
|
102
102
|
scopes?: string[];
|
|
103
103
|
}>;
|
|
@@ -109,7 +109,12 @@ export type SlateAuthWithToken<InputType extends {}, OutputType extends { token?
|
|
|
109
109
|
|
|
110
110
|
getDefaultInput?: () => Promise<InputType>;
|
|
111
111
|
|
|
112
|
-
getProfile?: (ctx: {
|
|
112
|
+
getProfile?: (ctx: {
|
|
113
|
+
output: OutputType;
|
|
114
|
+
input: InputType;
|
|
115
|
+
scopes: string[];
|
|
116
|
+
config?: SlateAuthCallbackConfig;
|
|
117
|
+
}) => Promise<{
|
|
113
118
|
profile: Record<string, any>;
|
|
114
119
|
}>;
|
|
115
120
|
};
|
|
@@ -124,7 +129,7 @@ export type SlateAuthWithServiceAccount<InputType extends {}, OutputType extends
|
|
|
124
129
|
|
|
125
130
|
docs?: SlateAuthDocsReference[];
|
|
126
131
|
|
|
127
|
-
getOutput: (ctx: { input: InputType }) => Promise<{
|
|
132
|
+
getOutput: (ctx: { input: InputType; config?: SlateAuthCallbackConfig }) => Promise<{
|
|
128
133
|
output: OutputType;
|
|
129
134
|
scopes?: string[];
|
|
130
135
|
}>;
|
|
@@ -136,7 +141,12 @@ export type SlateAuthWithServiceAccount<InputType extends {}, OutputType extends
|
|
|
136
141
|
|
|
137
142
|
getDefaultInput?: () => Promise<InputType>;
|
|
138
143
|
|
|
139
|
-
getProfile?: (ctx: {
|
|
144
|
+
getProfile?: (ctx: {
|
|
145
|
+
output: OutputType;
|
|
146
|
+
input: InputType;
|
|
147
|
+
scopes: string[];
|
|
148
|
+
config?: SlateAuthCallbackConfig;
|
|
149
|
+
}) => Promise<{
|
|
140
150
|
profile: Record<string, any>;
|
|
141
151
|
}>;
|
|
142
152
|
};
|
|
@@ -151,7 +161,7 @@ export type SlateAuthWithCustomData<InputType extends {}, OutputType extends {}>
|
|
|
151
161
|
|
|
152
162
|
docs?: SlateAuthDocsReference[];
|
|
153
163
|
|
|
154
|
-
getOutput: (ctx: { input: InputType }) => Promise<{
|
|
164
|
+
getOutput: (ctx: { input: InputType; config?: SlateAuthCallbackConfig }) => Promise<{
|
|
155
165
|
output: OutputType;
|
|
156
166
|
scopes?: string[];
|
|
157
167
|
}>;
|
|
@@ -162,6 +172,7 @@ export type SlateAuthWithCustomData<InputType extends {}, OutputType extends {}>
|
|
|
162
172
|
clientId: string;
|
|
163
173
|
clientSecret: string;
|
|
164
174
|
scopes: string[];
|
|
175
|
+
config?: SlateAuthCallbackConfig;
|
|
165
176
|
}) => Promise<{
|
|
166
177
|
output: OutputType;
|
|
167
178
|
input?: InputType;
|
|
@@ -174,7 +185,12 @@ export type SlateAuthWithCustomData<InputType extends {}, OutputType extends {}>
|
|
|
174
185
|
|
|
175
186
|
getDefaultInput?: () => Promise<InputType>;
|
|
176
187
|
|
|
177
|
-
getProfile?: (ctx: {
|
|
188
|
+
getProfile?: (ctx: {
|
|
189
|
+
output: OutputType;
|
|
190
|
+
input: InputType;
|
|
191
|
+
scopes: string[];
|
|
192
|
+
config?: SlateAuthCallbackConfig;
|
|
193
|
+
}) => Promise<{
|
|
178
194
|
profile: Record<string, any>;
|
|
179
195
|
}>;
|
|
180
196
|
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { AxiosResponse } from 'axios';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { createAuthenticatedAxios, requestAxios, requestAxiosData } from './index';
|
|
4
|
+
|
|
5
|
+
describe('createAuthenticatedAxios', () => {
|
|
6
|
+
it('sets auth and JSON headers while preserving caller headers', () => {
|
|
7
|
+
let client = createAuthenticatedAxios({
|
|
8
|
+
baseURL: 'https://example.test',
|
|
9
|
+
authHeader: {
|
|
10
|
+
value: 'Bearer token'
|
|
11
|
+
},
|
|
12
|
+
headers: {
|
|
13
|
+
Accept: 'application/json'
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(client.defaults.baseURL).toBe('https://example.test');
|
|
18
|
+
expect(client.defaults.headers.Authorization).toBe('Bearer token');
|
|
19
|
+
expect(client.defaults.headers['Content-Type']).toBe('application/json');
|
|
20
|
+
expect(client.defaults.headers.Accept).toBe('application/json');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('allows custom auth header names and disabling content type defaults', () => {
|
|
24
|
+
let client = createAuthenticatedAxios({
|
|
25
|
+
authHeader: {
|
|
26
|
+
name: 'X-API-Key',
|
|
27
|
+
value: 'secret'
|
|
28
|
+
},
|
|
29
|
+
contentType: false
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(client.defaults.headers['X-API-Key']).toBe('secret');
|
|
33
|
+
expect(client.defaults.headers['Content-Type']).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('requestAxios helpers', () => {
|
|
38
|
+
it('returns full responses or response data', async () => {
|
|
39
|
+
let response = {
|
|
40
|
+
config: {} as AxiosResponse<{ ok: boolean }>['config'],
|
|
41
|
+
data: { ok: true },
|
|
42
|
+
headers: {},
|
|
43
|
+
status: 200,
|
|
44
|
+
statusText: 'OK'
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
await expect(
|
|
48
|
+
requestAxios(
|
|
49
|
+
'get thing',
|
|
50
|
+
async () => response,
|
|
51
|
+
error => error
|
|
52
|
+
)
|
|
53
|
+
).resolves.toBe(response);
|
|
54
|
+
await expect(
|
|
55
|
+
requestAxiosData(
|
|
56
|
+
'get thing',
|
|
57
|
+
async () => response,
|
|
58
|
+
error => error
|
|
59
|
+
)
|
|
60
|
+
).resolves.toEqual({ ok: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('maps failures with the operation name', async () => {
|
|
64
|
+
let upstream = new Error('upstream failed');
|
|
65
|
+
let mapped = new Error('mapped failure');
|
|
66
|
+
let seenOperation: string | undefined;
|
|
67
|
+
|
|
68
|
+
await expect(
|
|
69
|
+
requestAxiosData(
|
|
70
|
+
'create thing',
|
|
71
|
+
async () => {
|
|
72
|
+
throw upstream;
|
|
73
|
+
},
|
|
74
|
+
(_error, operation) => {
|
|
75
|
+
seenOperation = operation;
|
|
76
|
+
return mapped;
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
).rejects.toBe(mapped);
|
|
80
|
+
|
|
81
|
+
expect(seenOperation).toBe('create thing');
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/axios/index.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
AxiosInstance,
|
|
3
|
+
AxiosResponse,
|
|
4
|
+
CreateAxiosDefaults,
|
|
5
|
+
InternalAxiosRequestConfig
|
|
6
|
+
} from 'axios';
|
|
2
7
|
import baseAxios, { getAdapter, isAxiosError } from 'axios';
|
|
3
8
|
import { getCurrentContext } from '../context/hook';
|
|
4
9
|
import type { SlateAxiosErrorOptions } from '../error';
|
|
@@ -13,6 +18,17 @@ export interface SlateAxiosDefaults extends CreateAxiosDefaults {
|
|
|
13
18
|
errorMapping?: SlateAxiosErrorOptions;
|
|
14
19
|
}
|
|
15
20
|
|
|
21
|
+
export type SlateAxiosErrorAdapter = (error: unknown, operation: string) => unknown;
|
|
22
|
+
|
|
23
|
+
export interface SlateAuthenticatedAxiosDefaults extends SlateAxiosDefaults {
|
|
24
|
+
authHeader?: {
|
|
25
|
+
name?: string;
|
|
26
|
+
value: string;
|
|
27
|
+
};
|
|
28
|
+
contentType?: string | false;
|
|
29
|
+
errorAdapter?: (error: unknown) => unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
let applySlateInterceptors = (
|
|
17
33
|
instance: AxiosInstance,
|
|
18
34
|
errorMapping?: SlateAxiosErrorOptions
|
|
@@ -73,4 +89,46 @@ export let createAxios = (config?: SlateAxiosDefaults) => {
|
|
|
73
89
|
return instance;
|
|
74
90
|
};
|
|
75
91
|
|
|
92
|
+
export let createAuthenticatedAxios = (config: SlateAuthenticatedAxiosDefaults) => {
|
|
93
|
+
let { authHeader, contentType = 'application/json', errorAdapter, ...axiosConfig } = config;
|
|
94
|
+
let instance = createAxios({
|
|
95
|
+
...axiosConfig,
|
|
96
|
+
headers: {
|
|
97
|
+
...(contentType === false ? {} : { 'Content-Type': contentType }),
|
|
98
|
+
...(authHeader ? { [authHeader.name ?? 'Authorization']: authHeader.value } : {}),
|
|
99
|
+
...axiosConfig.headers
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (errorAdapter) {
|
|
104
|
+
instance.interceptors.response.use(
|
|
105
|
+
response => response,
|
|
106
|
+
error => Promise.reject(errorAdapter(error))
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return instance;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export let requestAxios = async <T = unknown>(
|
|
114
|
+
operation: string,
|
|
115
|
+
request: () => Promise<AxiosResponse<T>>,
|
|
116
|
+
errorAdapter: SlateAxiosErrorAdapter
|
|
117
|
+
) => {
|
|
118
|
+
try {
|
|
119
|
+
return await request();
|
|
120
|
+
} catch (error) {
|
|
121
|
+
throw errorAdapter(error, operation);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export let requestAxiosData = async <T = unknown>(
|
|
126
|
+
operation: string,
|
|
127
|
+
request: () => Promise<AxiosResponse<T>>,
|
|
128
|
+
errorAdapter: SlateAxiosErrorAdapter
|
|
129
|
+
) => {
|
|
130
|
+
let response = await requestAxios(operation, request, errorAdapter);
|
|
131
|
+
return response.data as T;
|
|
132
|
+
};
|
|
133
|
+
|
|
76
134
|
export let axios = createAxios();
|
package/src/context/context.ts
CHANGED
|
@@ -48,6 +48,14 @@ export class SlateContext<ConfigType extends {}, AuthType extends {}, InputType
|
|
|
48
48
|
: never;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
get registrationDetails() {
|
|
52
|
+
return Object.freeze(
|
|
53
|
+
(this.#input as any).registrationDetails
|
|
54
|
+
) as 'registrationDetails' extends keyof InputType
|
|
55
|
+
? InputType['registrationDetails']
|
|
56
|
+
: never;
|
|
57
|
+
}
|
|
58
|
+
|
|
51
59
|
get auth() {
|
|
52
60
|
return Object.freeze(this.#auth);
|
|
53
61
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createHmacSignature, verifyHmacSignature } from './hmac';
|
|
3
|
+
|
|
4
|
+
describe('createHmacSignature', () => {
|
|
5
|
+
it('creates prefixed hexadecimal HMAC signatures', () => {
|
|
6
|
+
expect(
|
|
7
|
+
createHmacSignature({
|
|
8
|
+
secret: 'secret',
|
|
9
|
+
payload: 'payload',
|
|
10
|
+
digest: 'hex',
|
|
11
|
+
prefix: 'v0='
|
|
12
|
+
})
|
|
13
|
+
).toBe('v0=b82fcb791acec57859b989b430a826488ce2e479fdf92326bd0a2e8375a42ba4');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('verifyHmacSignature', () => {
|
|
18
|
+
it('verifies prefixed hexadecimal HMAC signatures', () => {
|
|
19
|
+
expect(
|
|
20
|
+
verifyHmacSignature({
|
|
21
|
+
secret: 'secret',
|
|
22
|
+
payload: 'payload',
|
|
23
|
+
signature: 'v0=b82fcb791acec57859b989b430a826488ce2e479fdf92326bd0a2e8375a42ba4',
|
|
24
|
+
digest: 'hex',
|
|
25
|
+
prefix: 'v0='
|
|
26
|
+
})
|
|
27
|
+
).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rejects incorrect signatures, including signatures of a different length', () => {
|
|
31
|
+
expect(
|
|
32
|
+
verifyHmacSignature({
|
|
33
|
+
secret: 'secret',
|
|
34
|
+
payload: 'payload',
|
|
35
|
+
signature: 'invalid',
|
|
36
|
+
digest: 'base64'
|
|
37
|
+
})
|
|
38
|
+
).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'crypto';
|
|
2
|
+
|
|
3
|
+
export interface CreateHmacSignatureOptions {
|
|
4
|
+
secret: string | Uint8Array;
|
|
5
|
+
payload: string | Uint8Array;
|
|
6
|
+
algorithm?: string;
|
|
7
|
+
digest: 'hex' | 'base64';
|
|
8
|
+
prefix?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface VerifyHmacSignatureOptions extends CreateHmacSignatureOptions {
|
|
12
|
+
signature: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export let createHmacSignature = ({
|
|
16
|
+
secret,
|
|
17
|
+
payload,
|
|
18
|
+
algorithm = 'sha256',
|
|
19
|
+
digest,
|
|
20
|
+
prefix = ''
|
|
21
|
+
}: CreateHmacSignatureOptions) =>
|
|
22
|
+
`${prefix}${createHmac(algorithm, secret).update(payload).digest(digest)}`;
|
|
23
|
+
|
|
24
|
+
export let verifyHmacSignature = (options: VerifyHmacSignatureOptions) => {
|
|
25
|
+
let expected = createHmacSignature(options);
|
|
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);
|
|
30
|
+
|
|
31
|
+
return timingSafeEqual(comparableSignature, expectedBytes) && lengthsMatch;
|
|
32
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './hmac';
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { ServiceError } from '@lowerdeck/error';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { buildApiServiceError, collectApiErrorDetails, createApiServiceError } from './api';
|
|
4
|
+
import { SlateError } from './base';
|
|
5
|
+
|
|
6
|
+
describe('API service error helpers', () => {
|
|
7
|
+
it('collects unique nested API error details', () => {
|
|
8
|
+
let details: string[] = [];
|
|
9
|
+
|
|
10
|
+
collectApiErrorDetails(
|
|
11
|
+
{
|
|
12
|
+
message: 'Invalid request',
|
|
13
|
+
code: 'bad_request',
|
|
14
|
+
errors: [
|
|
15
|
+
{
|
|
16
|
+
detail: 'Missing name'
|
|
17
|
+
},
|
|
18
|
+
'Missing name'
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
details
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
expect(details).toEqual(['Invalid request', 'bad_request', 'Missing name']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('builds provider API ServiceErrors with upstream metadata', () => {
|
|
28
|
+
let parent = new Error('Request failed');
|
|
29
|
+
(parent as any).response = {
|
|
30
|
+
status: 429,
|
|
31
|
+
statusText: 'Too Many Requests',
|
|
32
|
+
data: {
|
|
33
|
+
title: 'Rate limit',
|
|
34
|
+
details: [{ message: 'Retry later' }]
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let error = buildApiServiceError(parent, {
|
|
39
|
+
providerLabel: 'Example',
|
|
40
|
+
operation: 'list widgets',
|
|
41
|
+
reason: 'example_api_error',
|
|
42
|
+
detailKeys: ['title', 'message'],
|
|
43
|
+
nestedKeys: ['details'],
|
|
44
|
+
extractUpstreamCode: () => 'RATE_LIMITED'
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(error).toBeInstanceOf(ServiceError);
|
|
48
|
+
expect(error.data.message).toBe(
|
|
49
|
+
'Example API list widgets failed: HTTP 429 Too Many Requests: Rate limit - Retry later'
|
|
50
|
+
);
|
|
51
|
+
expect(error.data.reason).toBe('example_api_error');
|
|
52
|
+
expect(error.data.upstreamStatus).toBe(429);
|
|
53
|
+
expect(error.data.upstreamCode).toBe('RATE_LIMITED');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('recovers the upstream status from interceptor-normalized SlateErrors', () => {
|
|
57
|
+
// Runtime axios instances reject with SlateError, not AxiosError: the
|
|
58
|
+
// response interceptor consumes error.response, so the status only
|
|
59
|
+
// survives at data.upstream.status / data.status.
|
|
60
|
+
let parent = new SlateError({
|
|
61
|
+
code: 'not_found',
|
|
62
|
+
message: 'Method not found.',
|
|
63
|
+
upstream: { status: 404 }
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
let error = buildApiServiceError(parent, {
|
|
67
|
+
providerLabel: 'Example',
|
|
68
|
+
operation: 'search messages',
|
|
69
|
+
reason: 'example_api_error'
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(error.data.upstreamStatus).toBe(404);
|
|
73
|
+
expect(error.data.message).toBe(
|
|
74
|
+
'Example API search messages failed: HTTP 404: Method not found.'
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('allows provider-specific status and message extraction', () => {
|
|
79
|
+
let error = buildApiServiceError(
|
|
80
|
+
{
|
|
81
|
+
status: 503,
|
|
82
|
+
data: {
|
|
83
|
+
message: 'Unavailable'
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
providerLabel: 'Example',
|
|
88
|
+
reason: 'example_api_error',
|
|
89
|
+
extractStatus: (input, _response, helpers) =>
|
|
90
|
+
helpers.isRecord(input) && typeof input.status === 'number'
|
|
91
|
+
? input.status
|
|
92
|
+
: undefined,
|
|
93
|
+
extractMessage: (input, helpers) => {
|
|
94
|
+
if (!helpers.isRecord(input) || !helpers.isRecord(input.data)) return undefined;
|
|
95
|
+
return typeof input.data.message === 'string' ? input.data.message : undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(error.data.message).toBe('Example API request failed: HTTP 503: Unavailable');
|
|
101
|
+
expect(error.data.upstreamStatus).toBe(503);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('passes through existing ServiceErrors', () => {
|
|
105
|
+
let existing = createApiServiceError('Already normalized');
|
|
106
|
+
|
|
107
|
+
expect(
|
|
108
|
+
buildApiServiceError(existing, {
|
|
109
|
+
providerLabel: 'Example',
|
|
110
|
+
reason: 'example_api_error'
|
|
111
|
+
})
|
|
112
|
+
).toBe(existing);
|
|
113
|
+
});
|
|
114
|
+
});
|