@slates/provider 1.0.0-rc.16 → 1.0.0-rc.18
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 +639 -21
- package/dist/index.d.cts +290 -29
- package/dist/index.d.ts +290 -29
- package/dist/index.module.js +606 -18
- package/package.json +2 -2
- package/src/action/action.ts +64 -2
- package/src/action/builder.test.ts +136 -0
- package/src/action/builder.ts +73 -13
- package/src/action/index.ts +2 -1
- package/src/action/publicTool.ts +36 -0
- package/src/action/tool.ts +15 -3
- package/src/action/trigger.ts +9 -1
- package/src/adapter/adapter.ts +38 -0
- package/src/adapter/index.test.ts +322 -0
- package/src/adapter/index.ts +2 -0
- package/src/adapter/spec.ts +154 -0
- 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/axios/trace.ts +3 -3
- package/src/context/context.ts +37 -14
- package/src/context/hook.ts +5 -10
- 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 +5 -0
- package/src/object/index.test.ts +38 -0
- package/src/object/index.ts +12 -0
- package/src/specification/slate.ts +38 -2
- 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
package/src/error/api.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { badRequestError, type ErrorRecord, ServiceError } from '@lowerdeck/error';
|
|
2
|
+
|
|
3
|
+
export type ApiErrorStatus = number | string;
|
|
4
|
+
export type ApiServiceError = ServiceError<ErrorRecord<any, any>>;
|
|
5
|
+
|
|
6
|
+
export type ApiErrorResponse = {
|
|
7
|
+
status?: ApiErrorStatus;
|
|
8
|
+
statusText?: string;
|
|
9
|
+
data?: unknown;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type ApiErrorDetailOptions = {
|
|
13
|
+
detailKeys?: readonly string[];
|
|
14
|
+
nestedKeys?: readonly string[];
|
|
15
|
+
includeNumbers?: boolean;
|
|
16
|
+
separator?: string;
|
|
17
|
+
fallbackMessage?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ApiErrorBuildHelpers = {
|
|
21
|
+
collectDetails: (value: unknown, details: string[], options?: ApiErrorDetailOptions) => void;
|
|
22
|
+
extractMessage: (error: unknown, options?: ApiErrorDetailOptions) => string;
|
|
23
|
+
getResponse: (error: unknown) => ApiErrorResponse | undefined;
|
|
24
|
+
getStatus: (error: unknown) => ApiErrorStatus | undefined;
|
|
25
|
+
isRecord: (value: unknown) => value is Record<string, unknown>;
|
|
26
|
+
statusLabelFor: (status?: ApiErrorStatus, statusText?: string) => string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type BuildApiServiceErrorOptions = ApiErrorDetailOptions & {
|
|
30
|
+
providerLabel: string;
|
|
31
|
+
reason: string;
|
|
32
|
+
operation?: string;
|
|
33
|
+
extractMessage?: (error: unknown, helpers: ApiErrorBuildHelpers) => string | undefined;
|
|
34
|
+
extractResponse?: (
|
|
35
|
+
error: unknown,
|
|
36
|
+
helpers: ApiErrorBuildHelpers
|
|
37
|
+
) => ApiErrorResponse | undefined;
|
|
38
|
+
extractStatus?: (
|
|
39
|
+
error: unknown,
|
|
40
|
+
response: ApiErrorResponse | undefined,
|
|
41
|
+
helpers: ApiErrorBuildHelpers
|
|
42
|
+
) => ApiErrorStatus | undefined;
|
|
43
|
+
extractUpstreamCode?: (
|
|
44
|
+
error: unknown,
|
|
45
|
+
response: ApiErrorResponse | undefined,
|
|
46
|
+
helpers: ApiErrorBuildHelpers
|
|
47
|
+
) => string | undefined;
|
|
48
|
+
formatMessage?: (parts: {
|
|
49
|
+
providerLabel: string;
|
|
50
|
+
operation: string;
|
|
51
|
+
statusLabel: string;
|
|
52
|
+
message: string;
|
|
53
|
+
response?: ApiErrorResponse;
|
|
54
|
+
status?: ApiErrorStatus;
|
|
55
|
+
}) => string;
|
|
56
|
+
parent?: unknown;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
let DEFAULT_DETAIL_KEYS = ['message', 'detail', 'error', 'error_description', 'code'];
|
|
60
|
+
let DEFAULT_NESTED_KEYS = ['errors'];
|
|
61
|
+
|
|
62
|
+
export let isApiErrorRecord = (value: unknown): value is Record<string, unknown> =>
|
|
63
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
64
|
+
|
|
65
|
+
let pushDetail = (
|
|
66
|
+
details: string[],
|
|
67
|
+
value: unknown,
|
|
68
|
+
options: Pick<ApiErrorDetailOptions, 'includeNumbers'> = {}
|
|
69
|
+
) => {
|
|
70
|
+
if (
|
|
71
|
+
typeof value !== 'string' &&
|
|
72
|
+
(options.includeNumbers === false || typeof value !== 'number')
|
|
73
|
+
) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let detail = String(value).trim();
|
|
78
|
+
if (detail && !details.includes(detail)) {
|
|
79
|
+
details.push(detail);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export let collectApiErrorDetails = (
|
|
84
|
+
value: unknown,
|
|
85
|
+
details: string[],
|
|
86
|
+
options: ApiErrorDetailOptions = {}
|
|
87
|
+
) => {
|
|
88
|
+
if (Array.isArray(value)) {
|
|
89
|
+
for (let item of value) {
|
|
90
|
+
collectApiErrorDetails(item, details, options);
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!isApiErrorRecord(value)) {
|
|
96
|
+
pushDetail(details, value, options);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (let key of options.detailKeys ?? DEFAULT_DETAIL_KEYS) {
|
|
101
|
+
pushDetail(details, value[key], options);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (let key of options.nestedKeys ?? DEFAULT_NESTED_KEYS) {
|
|
105
|
+
collectApiErrorDetails(value[key], details, options);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Runtime axios instances from createAxios reject with SlateError (the raw
|
|
110
|
+
// AxiosError is consumed by the response interceptor), so the upstream HTTP
|
|
111
|
+
// status only survives inside SlateError.data — read it back from there or
|
|
112
|
+
// every buildApiServiceError caller loses status-based handling in production.
|
|
113
|
+
let getSlateErrorResponse = (error: Record<string, unknown>): ApiErrorResponse | undefined => {
|
|
114
|
+
if (error.name !== 'SlateError' || !isApiErrorRecord(error.data)) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let data = error.data;
|
|
119
|
+
let upstream = isApiErrorRecord(data.upstream) ? data.upstream : undefined;
|
|
120
|
+
let status = upstream?.status ?? data.status;
|
|
121
|
+
return typeof status === 'number' || typeof status === 'string' ? { status } : undefined;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export let getApiErrorResponse = (error: unknown): ApiErrorResponse | undefined => {
|
|
125
|
+
if (!isApiErrorRecord(error)) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
if (!isApiErrorRecord(error.response)) {
|
|
129
|
+
return getSlateErrorResponse(error);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let response = error.response;
|
|
133
|
+
return {
|
|
134
|
+
status:
|
|
135
|
+
typeof response.status === 'number' || typeof response.status === 'string'
|
|
136
|
+
? response.status
|
|
137
|
+
: undefined,
|
|
138
|
+
statusText: typeof response.statusText === 'string' ? response.statusText : undefined,
|
|
139
|
+
data: response.data
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export let getApiErrorStatus = (error: unknown): ApiErrorStatus | undefined =>
|
|
144
|
+
getApiErrorResponse(error)?.status;
|
|
145
|
+
|
|
146
|
+
export let statusLabelForApiError = (status?: ApiErrorStatus, statusText?: string) =>
|
|
147
|
+
status !== undefined ? `HTTP ${status}${statusText ? ` ${statusText}` : ''}: ` : '';
|
|
148
|
+
|
|
149
|
+
export let extractApiErrorMessage = (
|
|
150
|
+
error: unknown,
|
|
151
|
+
options: ApiErrorDetailOptions & { response?: ApiErrorResponse } = {}
|
|
152
|
+
) => {
|
|
153
|
+
let details: string[] = [];
|
|
154
|
+
let response = options.response ?? getApiErrorResponse(error);
|
|
155
|
+
|
|
156
|
+
collectApiErrorDetails(response?.data, details, options);
|
|
157
|
+
|
|
158
|
+
if (details.length > 0) {
|
|
159
|
+
return details.join(options.separator ?? ' - ');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (error instanceof Error && error.message) {
|
|
163
|
+
return error.message;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return options.fallbackMessage ?? 'Unknown error';
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export let createApiServiceError = (
|
|
170
|
+
message: string,
|
|
171
|
+
options: {
|
|
172
|
+
reason?: string;
|
|
173
|
+
upstreamStatus?: ApiErrorStatus;
|
|
174
|
+
upstreamCode?: string;
|
|
175
|
+
parent?: unknown;
|
|
176
|
+
} = {}
|
|
177
|
+
) => {
|
|
178
|
+
let error = new ServiceError(badRequestError({ message }));
|
|
179
|
+
|
|
180
|
+
if (options.reason !== undefined) {
|
|
181
|
+
error.data.reason = options.reason;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if ('upstreamStatus' in options) {
|
|
185
|
+
error.data.upstreamStatus = options.upstreamStatus;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if ('upstreamCode' in options) {
|
|
189
|
+
error.data.upstreamCode = options.upstreamCode;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (options.parent instanceof Error) {
|
|
193
|
+
error.setParent(options.parent);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return error;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
let helpers: ApiErrorBuildHelpers = {
|
|
200
|
+
collectDetails: collectApiErrorDetails,
|
|
201
|
+
extractMessage: extractApiErrorMessage,
|
|
202
|
+
getResponse: getApiErrorResponse,
|
|
203
|
+
getStatus: getApiErrorStatus,
|
|
204
|
+
isRecord: isApiErrorRecord,
|
|
205
|
+
statusLabelFor: statusLabelForApiError
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export let buildApiServiceError = (error: unknown, options: BuildApiServiceErrorOptions) => {
|
|
209
|
+
if (error instanceof ServiceError) {
|
|
210
|
+
return error;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let operation = options.operation ?? 'request';
|
|
214
|
+
let response = options.extractResponse?.(error, helpers) ?? getApiErrorResponse(error);
|
|
215
|
+
let status = options.extractStatus?.(error, response, helpers) ?? response?.status;
|
|
216
|
+
let message =
|
|
217
|
+
options.extractMessage?.(error, helpers) ??
|
|
218
|
+
extractApiErrorMessage(error, { ...options, response });
|
|
219
|
+
let statusLabel = statusLabelForApiError(status, response?.statusText);
|
|
220
|
+
let fullMessage =
|
|
221
|
+
options.formatMessage?.({
|
|
222
|
+
providerLabel: options.providerLabel,
|
|
223
|
+
operation,
|
|
224
|
+
statusLabel,
|
|
225
|
+
message,
|
|
226
|
+
response,
|
|
227
|
+
status
|
|
228
|
+
}) ?? `${options.providerLabel} API ${operation} failed: ${statusLabel}${message}`;
|
|
229
|
+
|
|
230
|
+
return createApiServiceError(fullMessage, {
|
|
231
|
+
reason: options.reason,
|
|
232
|
+
upstreamStatus: status,
|
|
233
|
+
upstreamCode: options.extractUpstreamCode?.(error, response, helpers),
|
|
234
|
+
parent: options.parent ?? error
|
|
235
|
+
});
|
|
236
|
+
};
|
package/src/error/index.ts
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getBase64ByteLength, getResponseHeaderValue } from './index';
|
|
3
|
+
|
|
4
|
+
describe('file helpers', () => {
|
|
5
|
+
it('finds response headers case-insensitively from plain objects', () => {
|
|
6
|
+
expect(
|
|
7
|
+
getResponseHeaderValue(
|
|
8
|
+
{
|
|
9
|
+
'Content-Type': 'application/pdf',
|
|
10
|
+
'x-empty': ''
|
|
11
|
+
},
|
|
12
|
+
'content-type'
|
|
13
|
+
)
|
|
14
|
+
).toBe('application/pdf');
|
|
15
|
+
expect(getResponseHeaderValue({ 'x-empty': '' }, 'X-Empty')).toBe('');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns the first string value from array headers', () => {
|
|
19
|
+
expect(
|
|
20
|
+
getResponseHeaderValue({ 'content-type': ['text/plain', 'text/html'] }, 'content-type')
|
|
21
|
+
).toBe('text/plain');
|
|
22
|
+
expect(
|
|
23
|
+
getResponseHeaderValue({ 'content-type': [123, 'text/html'] }, 'content-type')
|
|
24
|
+
).toBe('text/html');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('supports Headers-like objects', () => {
|
|
28
|
+
let headers = new Headers({ 'content-type': 'image/png' });
|
|
29
|
+
|
|
30
|
+
expect(getResponseHeaderValue(headers, 'Content-Type')).toBe('image/png');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('computes decoded base64 byte length', () => {
|
|
34
|
+
expect(getBase64ByteLength(Buffer.from('hello').toString('base64'))).toBe(5);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export let getResponseHeaderValue = (headers: unknown, name: string): string | undefined => {
|
|
2
|
+
if (!headers) return undefined;
|
|
3
|
+
|
|
4
|
+
if (typeof (headers as { get?: unknown }).get === 'function') {
|
|
5
|
+
let value = (headers as { get(headerName: string): unknown }).get(name);
|
|
6
|
+
return typeof value === 'string' ? value : undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (typeof headers !== 'object') return undefined;
|
|
10
|
+
|
|
11
|
+
let lowerName = name.toLowerCase();
|
|
12
|
+
for (let [headerName, value] of Object.entries(headers as Record<string, unknown>)) {
|
|
13
|
+
if (headerName.toLowerCase() !== lowerName) continue;
|
|
14
|
+
|
|
15
|
+
if (Array.isArray(value)) {
|
|
16
|
+
let first = value.find(item => typeof item === 'string');
|
|
17
|
+
return typeof first === 'string' ? first : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return typeof value === 'string' ? value : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return undefined;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export let getBase64ByteLength = (contentBase64: string) =>
|
|
27
|
+
Buffer.from(contentBase64, 'base64').byteLength;
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
export * from './action';
|
|
2
2
|
export * from './action/attachment';
|
|
3
|
+
export * from './adapter';
|
|
3
4
|
export * from './auth';
|
|
4
5
|
export * from './axios';
|
|
5
6
|
export * from './config';
|
|
6
7
|
export * from './context';
|
|
8
|
+
export * from './crypto';
|
|
7
9
|
export * from './docs';
|
|
8
10
|
export * from './error';
|
|
11
|
+
export * from './file';
|
|
9
12
|
export * from './logger';
|
|
13
|
+
export * from './object';
|
|
10
14
|
export * from './specification';
|
|
15
|
+
export * from './webhook';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { pickDefined, setIfDefined } from './index';
|
|
3
|
+
|
|
4
|
+
describe('object helpers', () => {
|
|
5
|
+
it('omits only undefined values', () => {
|
|
6
|
+
expect(
|
|
7
|
+
pickDefined({
|
|
8
|
+
keepNull: null,
|
|
9
|
+
keepFalse: false,
|
|
10
|
+
keepZero: 0,
|
|
11
|
+
keepEmpty: '',
|
|
12
|
+
dropUndefined: undefined
|
|
13
|
+
})
|
|
14
|
+
).toEqual({
|
|
15
|
+
keepNull: null,
|
|
16
|
+
keepFalse: false,
|
|
17
|
+
keepZero: 0,
|
|
18
|
+
keepEmpty: ''
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('sets only defined values on an existing target', () => {
|
|
23
|
+
let target: Record<string, unknown> = {};
|
|
24
|
+
|
|
25
|
+
setIfDefined(target, 'nullValue', null);
|
|
26
|
+
setIfDefined(target, 'falseValue', false);
|
|
27
|
+
setIfDefined(target, 'zeroValue', 0);
|
|
28
|
+
setIfDefined(target, 'emptyValue', '');
|
|
29
|
+
setIfDefined(target, 'undefinedValue', undefined);
|
|
30
|
+
|
|
31
|
+
expect(target).toEqual({
|
|
32
|
+
nullValue: null,
|
|
33
|
+
falseValue: false,
|
|
34
|
+
zeroValue: 0,
|
|
35
|
+
emptyValue: ''
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export let pickDefined = <T extends object>(value: T) =>
|
|
2
|
+
Object.fromEntries(
|
|
3
|
+
Object.entries(value).filter(([, child]) => child !== undefined)
|
|
4
|
+
) as Partial<T> & Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
export let setIfDefined = (target: Record<string, unknown>, key: string, value: unknown) => {
|
|
7
|
+
if (value !== undefined) {
|
|
8
|
+
target[key] = value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return target;
|
|
12
|
+
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { SlateTool, SlateTrigger } from '../action';
|
|
2
|
+
import type { SlateAdapter } from '../adapter';
|
|
3
|
+
import { SlateDeclarationError } from '../error';
|
|
2
4
|
import type { SlateSpecification } from './specification';
|
|
3
5
|
|
|
4
6
|
export class Slate<ConfigType extends {}, AuthType extends {}> {
|
|
@@ -7,15 +9,45 @@ export class Slate<ConfigType extends {}, AuthType extends {}> {
|
|
|
7
9
|
private readonly _actions: (
|
|
8
10
|
| SlateTrigger<ConfigType, AuthType, any, any>
|
|
9
11
|
| SlateTool<ConfigType, AuthType, any, any>
|
|
10
|
-
)[]
|
|
12
|
+
)[],
|
|
13
|
+
private readonly _adapters: SlateAdapter<ConfigType, AuthType>[]
|
|
11
14
|
) {}
|
|
12
15
|
|
|
13
16
|
static create<ConfigType extends {}, AuthType extends {}>(params: {
|
|
14
17
|
spec: SlateSpecification<ConfigType, AuthType>;
|
|
15
18
|
triggers: SlateTrigger<ConfigType, AuthType, any, any>[];
|
|
16
19
|
tools: SlateTool<ConfigType, AuthType, any, any>[];
|
|
20
|
+
adapters?: SlateAdapter<ConfigType, AuthType>[];
|
|
17
21
|
}) {
|
|
18
|
-
|
|
22
|
+
let adapters = params.adapters ?? [];
|
|
23
|
+
let actions = [...params.triggers, ...params.tools];
|
|
24
|
+
let seenAdapterIds = new Set<string>();
|
|
25
|
+
let seenActionKeys = new Map(actions.map(action => [action.key, action]));
|
|
26
|
+
|
|
27
|
+
for (let adapter of adapters) {
|
|
28
|
+
if (seenAdapterIds.has(adapter.id)) {
|
|
29
|
+
throw new SlateDeclarationError(
|
|
30
|
+
`Adapter "${adapter.id}" is registered more than once`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
seenAdapterIds.add(adapter.id);
|
|
34
|
+
|
|
35
|
+
for (let action of adapter.actions) {
|
|
36
|
+
let existing = seenActionKeys.get(action.key);
|
|
37
|
+
if (existing && existing !== action) {
|
|
38
|
+
throw new SlateDeclarationError(
|
|
39
|
+
`Adapter "${adapter.id}" action "${action.key}" conflicts with another action`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!existing) {
|
|
44
|
+
actions.push(action);
|
|
45
|
+
seenActionKeys.set(action.key, action);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return new Slate(params.spec, actions, adapters);
|
|
19
51
|
}
|
|
20
52
|
|
|
21
53
|
get spec() {
|
|
@@ -25,4 +57,8 @@ export class Slate<ConfigType extends {}, AuthType extends {}> {
|
|
|
25
57
|
get actions() {
|
|
26
58
|
return this._actions;
|
|
27
59
|
}
|
|
60
|
+
|
|
61
|
+
get adapters() {
|
|
62
|
+
return this._adapters;
|
|
63
|
+
}
|
|
28
64
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getGraphWebhookValidationResponse, graphWebhookHttp } from './graph';
|
|
3
|
+
|
|
4
|
+
describe('Microsoft Graph webhook validation', () => {
|
|
5
|
+
it('makes only validation requests synchronous', () => {
|
|
6
|
+
expect(graphWebhookHttp).toEqual({
|
|
7
|
+
methods: ['POST'],
|
|
8
|
+
sync: {
|
|
9
|
+
mode: 'match',
|
|
10
|
+
match: [{ hasQueryParam: 'validationToken' }]
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('echoes the URL-decoded validation token as plain text', () => {
|
|
16
|
+
let response = getGraphWebhookValidationResponse(
|
|
17
|
+
new Request(
|
|
18
|
+
'https://example.com/webhook?validationToken=Validation%3a+Testing+client+application+reachability',
|
|
19
|
+
{ method: 'POST' }
|
|
20
|
+
)
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
expect(response).toEqual({
|
|
24
|
+
status: 200,
|
|
25
|
+
headers: { 'content-type': 'text/plain' },
|
|
26
|
+
body: 'Validation: Testing client application reachability'
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('ignores ordinary change notifications', () => {
|
|
31
|
+
expect(
|
|
32
|
+
getGraphWebhookValidationResponse(
|
|
33
|
+
new Request('https://example.com/webhook', { method: 'POST' })
|
|
34
|
+
)
|
|
35
|
+
).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SlateWebhookHttpOptions, SlateWebhookHttpResponseInit } from '../action';
|
|
2
|
+
|
|
3
|
+
export const graphWebhookHttp = {
|
|
4
|
+
methods: ['POST'],
|
|
5
|
+
sync: {
|
|
6
|
+
mode: 'match',
|
|
7
|
+
match: [{ hasQueryParam: 'validationToken' }]
|
|
8
|
+
}
|
|
9
|
+
} satisfies SlateWebhookHttpOptions;
|
|
10
|
+
|
|
11
|
+
export let getGraphWebhookValidationResponse = (
|
|
12
|
+
request: Request
|
|
13
|
+
): SlateWebhookHttpResponseInit | null => {
|
|
14
|
+
let validationToken = new URL(request.url).searchParams.get('validationToken');
|
|
15
|
+
if (validationToken === null) return null;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
status: 200,
|
|
19
|
+
headers: { 'content-type': 'text/plain' },
|
|
20
|
+
body: validationToken
|
|
21
|
+
};
|
|
22
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getMetaWebhookVerificationResponse, metaWebhookHttp } from './meta';
|
|
3
|
+
|
|
4
|
+
let verificationRequest = (verifyToken = 'verify-token', challenge = 'challenge-value') =>
|
|
5
|
+
new Request(
|
|
6
|
+
`https://example.com/webhook?hub.mode=subscribe&hub.verify_token=${verifyToken}&hub.challenge=${challenge}`
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
describe('Meta webhook verification', () => {
|
|
10
|
+
it('makes only verification GET requests synchronous', () => {
|
|
11
|
+
expect(metaWebhookHttp).toEqual({
|
|
12
|
+
methods: ['GET', 'POST'],
|
|
13
|
+
sync: {
|
|
14
|
+
mode: 'match',
|
|
15
|
+
match: [{ method: 'GET', hasQueryParam: 'hub.mode' }]
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns the challenge for a matching configured token', () => {
|
|
21
|
+
expect(getMetaWebhookVerificationResponse(verificationRequest(), 'verify-token')).toEqual({
|
|
22
|
+
status: 200,
|
|
23
|
+
headers: { 'content-type': 'text/plain' },
|
|
24
|
+
body: 'challenge-value'
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('rejects a mismatched configured token', () => {
|
|
29
|
+
expect(
|
|
30
|
+
getMetaWebhookVerificationResponse(verificationRequest('wrong-token'), 'verify-token')
|
|
31
|
+
).toMatchObject({ status: 403 });
|
|
32
|
+
expect(
|
|
33
|
+
getMetaWebhookVerificationResponse(verificationRequest('wrong-token'), '')
|
|
34
|
+
).toMatchObject({ status: 403 });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('preserves legacy verification when no token is configured', () => {
|
|
38
|
+
expect(
|
|
39
|
+
getMetaWebhookVerificationResponse(verificationRequest('legacy-token'), undefined)
|
|
40
|
+
).toMatchObject({ status: 200, body: 'challenge-value' });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rejects malformed verification and ignores POST events', () => {
|
|
44
|
+
let malformed = new Request('https://example.com/webhook?hub.mode=subscribe');
|
|
45
|
+
let post = new Request('https://example.com/webhook', { method: 'POST' });
|
|
46
|
+
|
|
47
|
+
expect(getMetaWebhookVerificationResponse(malformed, undefined)).toMatchObject({
|
|
48
|
+
status: 400
|
|
49
|
+
});
|
|
50
|
+
expect(getMetaWebhookVerificationResponse(post, undefined)).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { SlateWebhookHttpOptions, SlateWebhookHttpResponseInit } from '../action';
|
|
2
|
+
|
|
3
|
+
export const metaWebhookHttp = {
|
|
4
|
+
methods: ['GET', 'POST'],
|
|
5
|
+
sync: {
|
|
6
|
+
mode: 'match',
|
|
7
|
+
match: [{ method: 'GET', hasQueryParam: 'hub.mode' }]
|
|
8
|
+
}
|
|
9
|
+
} satisfies SlateWebhookHttpOptions;
|
|
10
|
+
|
|
11
|
+
export let getMetaWebhookVerificationResponse = (
|
|
12
|
+
request: Request,
|
|
13
|
+
expectedVerifyToken: string | undefined
|
|
14
|
+
): SlateWebhookHttpResponseInit | null => {
|
|
15
|
+
if (request.method !== 'GET') return null;
|
|
16
|
+
|
|
17
|
+
let url = new URL(request.url);
|
|
18
|
+
let mode = url.searchParams.get('hub.mode');
|
|
19
|
+
let verifyToken = url.searchParams.get('hub.verify_token');
|
|
20
|
+
let challenge = url.searchParams.get('hub.challenge');
|
|
21
|
+
|
|
22
|
+
if (mode !== 'subscribe' || challenge === null) {
|
|
23
|
+
return {
|
|
24
|
+
status: 400,
|
|
25
|
+
headers: { 'content-type': 'text/plain' },
|
|
26
|
+
body: 'invalid webhook verification request'
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (expectedVerifyToken !== undefined && verifyToken !== expectedVerifyToken) {
|
|
31
|
+
return {
|
|
32
|
+
status: 403,
|
|
33
|
+
headers: { 'content-type': 'text/plain' },
|
|
34
|
+
body: 'invalid verify token'
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
status: 200,
|
|
40
|
+
headers: { 'content-type': 'text/plain' },
|
|
41
|
+
body: challenge
|
|
42
|
+
};
|
|
43
|
+
};
|