@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
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { SlateTool } from '../action/tool';
|
|
4
|
+
import { SlateAuth } from '../auth';
|
|
5
|
+
import { SlateConfig } from '../config';
|
|
6
|
+
import { Slate } from '../specification/slate';
|
|
7
|
+
import { SlateSpecification } from '../specification/specification';
|
|
8
|
+
import { SlateAdapterSpec } from './spec';
|
|
9
|
+
|
|
10
|
+
let createTestSpec = () => {
|
|
11
|
+
let config = SlateConfig.create(z.object({}));
|
|
12
|
+
let auth = SlateAuth.create<{}>().output(z.object({}));
|
|
13
|
+
|
|
14
|
+
return SlateSpecification.create({
|
|
15
|
+
key: 'demo',
|
|
16
|
+
name: 'Demo',
|
|
17
|
+
config,
|
|
18
|
+
auth
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('SlateAdapterSpec', () => {
|
|
23
|
+
it('creates adapter tools and triggers linked to the adapter, then registers them', () => {
|
|
24
|
+
let spec = createTestSpec();
|
|
25
|
+
let gmailAdapterSpec = SlateAdapterSpec.create({
|
|
26
|
+
id: 'gmail',
|
|
27
|
+
name: 'Gmail',
|
|
28
|
+
capabilities: [{ id: 'send_email', value: true }]
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let sendEmailTool = gmailAdapterSpec
|
|
32
|
+
.tool(spec, {
|
|
33
|
+
key: 'send_email',
|
|
34
|
+
name: 'Send Email'
|
|
35
|
+
})
|
|
36
|
+
.input(z.object({ to: z.string() }))
|
|
37
|
+
.output(z.object({ id: z.string() }))
|
|
38
|
+
.handleInvocation(async () => ({
|
|
39
|
+
output: { id: 'msg_1' },
|
|
40
|
+
message: 'sent'
|
|
41
|
+
}))
|
|
42
|
+
.build();
|
|
43
|
+
|
|
44
|
+
let emailReceivedTrigger = gmailAdapterSpec
|
|
45
|
+
.trigger(spec, {
|
|
46
|
+
key: 'email_received',
|
|
47
|
+
name: 'Email Received'
|
|
48
|
+
})
|
|
49
|
+
.input(z.object({ messageId: z.string() }))
|
|
50
|
+
.output(z.object({ type: z.literal('email.received') }))
|
|
51
|
+
.polling({
|
|
52
|
+
handleEvent: async ctx => ({
|
|
53
|
+
type: 'email.received',
|
|
54
|
+
id: ctx.input.messageId,
|
|
55
|
+
output: { type: 'email.received' as const }
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
.build();
|
|
59
|
+
|
|
60
|
+
expect(sendEmailTool.adapter).toBe('gmail');
|
|
61
|
+
expect(emailReceivedTrigger.adapter).toBe('gmail');
|
|
62
|
+
|
|
63
|
+
let gmailAdapter = gmailAdapterSpec.register({
|
|
64
|
+
tools: [sendEmailTool],
|
|
65
|
+
triggers: [emailReceivedTrigger]
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(gmailAdapter.id).toBe('gmail');
|
|
69
|
+
expect(gmailAdapter.name).toBe('Gmail');
|
|
70
|
+
expect(gmailAdapter.capabilities).toEqual([{ id: 'send_email', value: true }]);
|
|
71
|
+
expect(gmailAdapter.tools).toEqual([sendEmailTool]);
|
|
72
|
+
expect(gmailAdapter.triggers).toEqual([emailReceivedTrigger]);
|
|
73
|
+
|
|
74
|
+
let providerTool = SlateTool.create(spec, {
|
|
75
|
+
key: 'list_labels',
|
|
76
|
+
name: 'List Labels'
|
|
77
|
+
})
|
|
78
|
+
.input(z.object({}))
|
|
79
|
+
.output(z.object({ labels: z.array(z.string()) }))
|
|
80
|
+
.handleInvocation(async () => ({
|
|
81
|
+
output: { labels: [] },
|
|
82
|
+
message: 'ok'
|
|
83
|
+
}))
|
|
84
|
+
.build();
|
|
85
|
+
|
|
86
|
+
let slate = Slate.create({
|
|
87
|
+
spec,
|
|
88
|
+
tools: [providerTool, sendEmailTool],
|
|
89
|
+
triggers: [emailReceivedTrigger],
|
|
90
|
+
adapters: [gmailAdapter]
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(slate.actions.map(action => action.key)).toEqual([
|
|
94
|
+
'email_received',
|
|
95
|
+
'list_labels',
|
|
96
|
+
'send_email'
|
|
97
|
+
]);
|
|
98
|
+
expect(slate.adapters).toEqual([gmailAdapter]);
|
|
99
|
+
expect(slate.actions.filter(action => !action.adapter).map(action => action.key)).toEqual([
|
|
100
|
+
'list_labels'
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('includes adapter actions in the slate even when they are only passed via the adapter', () => {
|
|
105
|
+
let spec = createTestSpec();
|
|
106
|
+
let gmailAdapterSpec = SlateAdapterSpec.create({
|
|
107
|
+
id: 'gmail',
|
|
108
|
+
name: 'Gmail'
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
let sendEmailTool = gmailAdapterSpec
|
|
112
|
+
.tool(spec, {
|
|
113
|
+
key: 'send_email',
|
|
114
|
+
name: 'Send Email'
|
|
115
|
+
})
|
|
116
|
+
.input(z.object({}))
|
|
117
|
+
.output(z.object({}))
|
|
118
|
+
.handleInvocation(async () => ({
|
|
119
|
+
output: {},
|
|
120
|
+
message: 'ok'
|
|
121
|
+
}))
|
|
122
|
+
.build();
|
|
123
|
+
|
|
124
|
+
let gmailAdapter = gmailAdapterSpec.register({
|
|
125
|
+
tools: [sendEmailTool],
|
|
126
|
+
triggers: []
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
let slate = Slate.create({
|
|
130
|
+
spec,
|
|
131
|
+
tools: [],
|
|
132
|
+
triggers: [],
|
|
133
|
+
adapters: [gmailAdapter]
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(slate.actions).toEqual([sendEmailTool]);
|
|
137
|
+
expect(slate.actions[0]!.adapter).toBe('gmail');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('rejects tools created for a different adapter', () => {
|
|
141
|
+
let spec = createTestSpec();
|
|
142
|
+
let gmailAdapterSpec = SlateAdapterSpec.create({
|
|
143
|
+
id: 'gmail',
|
|
144
|
+
name: 'Gmail'
|
|
145
|
+
});
|
|
146
|
+
let outlookAdapterSpec = SlateAdapterSpec.create({
|
|
147
|
+
id: 'outlook',
|
|
148
|
+
name: 'Outlook'
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
let outlookTool = outlookAdapterSpec
|
|
152
|
+
.tool(spec, {
|
|
153
|
+
key: 'send_email',
|
|
154
|
+
name: 'Send Email'
|
|
155
|
+
})
|
|
156
|
+
.input(z.object({}))
|
|
157
|
+
.output(z.object({}))
|
|
158
|
+
.handleInvocation(async () => ({
|
|
159
|
+
output: {},
|
|
160
|
+
message: 'ok'
|
|
161
|
+
}))
|
|
162
|
+
.build();
|
|
163
|
+
|
|
164
|
+
expect(() =>
|
|
165
|
+
gmailAdapterSpec.register({
|
|
166
|
+
tools: [outlookTool],
|
|
167
|
+
triggers: []
|
|
168
|
+
})
|
|
169
|
+
).toThrow('Action "send_email" is linked to adapter "outlook", not "gmail"');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('rejects provider tools that were not created through the adapter helpers', () => {
|
|
173
|
+
let spec = createTestSpec();
|
|
174
|
+
let gmailAdapterSpec = SlateAdapterSpec.create({
|
|
175
|
+
id: 'gmail',
|
|
176
|
+
name: 'Gmail'
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
let providerTool = SlateTool.create(spec, {
|
|
180
|
+
key: 'send_email',
|
|
181
|
+
name: 'Send Email'
|
|
182
|
+
})
|
|
183
|
+
.input(z.object({}))
|
|
184
|
+
.output(z.object({}))
|
|
185
|
+
.handleInvocation(async () => ({
|
|
186
|
+
output: {},
|
|
187
|
+
message: 'ok'
|
|
188
|
+
}))
|
|
189
|
+
.build();
|
|
190
|
+
|
|
191
|
+
expect(() =>
|
|
192
|
+
gmailAdapterSpec.register({
|
|
193
|
+
tools: [providerTool],
|
|
194
|
+
triggers: []
|
|
195
|
+
})
|
|
196
|
+
).toThrow(
|
|
197
|
+
'Action "send_email" must be created with this adapter\'s tool(), publicTool(), or trigger() helpers'
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('rejects duplicate adapter action keys and duplicate adapter ids', () => {
|
|
202
|
+
let spec = createTestSpec();
|
|
203
|
+
let gmailAdapterSpec = SlateAdapterSpec.create({
|
|
204
|
+
id: 'gmail',
|
|
205
|
+
name: 'Gmail'
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
let sendEmailTool = gmailAdapterSpec
|
|
209
|
+
.tool(spec, {
|
|
210
|
+
key: 'send_email',
|
|
211
|
+
name: 'Send Email'
|
|
212
|
+
})
|
|
213
|
+
.input(z.object({}))
|
|
214
|
+
.output(z.object({}))
|
|
215
|
+
.handleInvocation(async () => ({
|
|
216
|
+
output: {},
|
|
217
|
+
message: 'ok'
|
|
218
|
+
}))
|
|
219
|
+
.build();
|
|
220
|
+
|
|
221
|
+
let sendEmailTrigger = gmailAdapterSpec
|
|
222
|
+
.trigger(spec, {
|
|
223
|
+
key: 'send_email',
|
|
224
|
+
name: 'Send Email Trigger'
|
|
225
|
+
})
|
|
226
|
+
.input(z.object({}))
|
|
227
|
+
.output(z.object({ type: z.string() }))
|
|
228
|
+
.polling({
|
|
229
|
+
handleEvent: async () => ({
|
|
230
|
+
type: 'email.sent',
|
|
231
|
+
id: '1',
|
|
232
|
+
output: { type: 'email.sent' }
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
.build();
|
|
236
|
+
|
|
237
|
+
expect(() =>
|
|
238
|
+
gmailAdapterSpec.register({
|
|
239
|
+
tools: [sendEmailTool],
|
|
240
|
+
triggers: [sendEmailTrigger]
|
|
241
|
+
})
|
|
242
|
+
).toThrow('Adapter "gmail" already has an action with key "send_email"');
|
|
243
|
+
|
|
244
|
+
let adapter = gmailAdapterSpec.register({
|
|
245
|
+
tools: [sendEmailTool],
|
|
246
|
+
triggers: []
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(() =>
|
|
250
|
+
Slate.create({
|
|
251
|
+
spec,
|
|
252
|
+
tools: [],
|
|
253
|
+
triggers: [],
|
|
254
|
+
adapters: [adapter, adapter]
|
|
255
|
+
})
|
|
256
|
+
).toThrow('Adapter "gmail" is registered more than once');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('rejects empty adapter ids and duplicate capability ids', () => {
|
|
260
|
+
expect(() =>
|
|
261
|
+
SlateAdapterSpec.create({
|
|
262
|
+
id: ' ',
|
|
263
|
+
name: 'Gmail'
|
|
264
|
+
})
|
|
265
|
+
).toThrow('Adapter id must be a non-empty string');
|
|
266
|
+
|
|
267
|
+
expect(() =>
|
|
268
|
+
SlateAdapterSpec.create({
|
|
269
|
+
id: 'gmail',
|
|
270
|
+
name: 'Gmail',
|
|
271
|
+
capabilities: [
|
|
272
|
+
{ id: 'send_email', value: true },
|
|
273
|
+
{ id: 'send_email', value: false }
|
|
274
|
+
]
|
|
275
|
+
})
|
|
276
|
+
).toThrow('Adapter capability "send_email" is defined more than once');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('creates public adapter tools that remain linked and public', () => {
|
|
280
|
+
let spec = createTestSpec();
|
|
281
|
+
let gmailAdapterSpec = SlateAdapterSpec.create({
|
|
282
|
+
id: 'gmail',
|
|
283
|
+
name: 'Gmail'
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
let setupTool = gmailAdapterSpec
|
|
287
|
+
.publicTool(spec, {
|
|
288
|
+
key: 'setup',
|
|
289
|
+
name: 'Setup'
|
|
290
|
+
})
|
|
291
|
+
.input(z.object({}))
|
|
292
|
+
.output(z.object({ ok: z.boolean() }))
|
|
293
|
+
.handleInvocation(async ctx => {
|
|
294
|
+
expect(ctx).not.toHaveProperty('config');
|
|
295
|
+
expect(ctx).not.toHaveProperty('auth');
|
|
296
|
+
return {
|
|
297
|
+
output: { ok: true },
|
|
298
|
+
message: 'ok'
|
|
299
|
+
};
|
|
300
|
+
})
|
|
301
|
+
.build();
|
|
302
|
+
|
|
303
|
+
expect(setupTool.adapter).toBe('gmail');
|
|
304
|
+
expect(setupTool.isPublic).toBe(true);
|
|
305
|
+
|
|
306
|
+
let gmailAdapter = gmailAdapterSpec.register({
|
|
307
|
+
tools: [setupTool],
|
|
308
|
+
triggers: []
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
let slate = Slate.create({
|
|
312
|
+
spec,
|
|
313
|
+
tools: [setupTool],
|
|
314
|
+
triggers: [],
|
|
315
|
+
adapters: [gmailAdapter]
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
expect(slate.actions.map(action => action.key)).toEqual(['setup']);
|
|
319
|
+
expect(slate.actions[0]!.isPublic).toBe(true);
|
|
320
|
+
expect(slate.actions[0]!.adapter).toBe('gmail');
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type SlateActionParameters,
|
|
3
|
+
SlatePublicTool,
|
|
4
|
+
SlateTool,
|
|
5
|
+
SlateTrigger
|
|
6
|
+
} from '../action';
|
|
7
|
+
import { SlateDeclarationError } from '../error';
|
|
8
|
+
import type { SlateSpecification } from '../specification/specification';
|
|
9
|
+
import { SlateAdapter } from './adapter';
|
|
10
|
+
|
|
11
|
+
export interface SlateAdapterCapability {
|
|
12
|
+
id: string;
|
|
13
|
+
value: any;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SlateAdapterSpecParameters {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
capabilities?: SlateAdapterCapability[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class SlateAdapterSpec {
|
|
23
|
+
private constructor(
|
|
24
|
+
private readonly _params: {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
capabilities: SlateAdapterCapability[];
|
|
28
|
+
}
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
static create(params: SlateAdapterSpecParameters): SlateAdapterSpec {
|
|
32
|
+
let id = params.id?.trim();
|
|
33
|
+
let name = params.name?.trim();
|
|
34
|
+
|
|
35
|
+
if (!id) {
|
|
36
|
+
throw new SlateDeclarationError('Adapter id must be a non-empty string');
|
|
37
|
+
}
|
|
38
|
+
if (!name) {
|
|
39
|
+
throw new SlateDeclarationError('Adapter name must be a non-empty string');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return new SlateAdapterSpec({
|
|
43
|
+
id,
|
|
44
|
+
name,
|
|
45
|
+
capabilities: validateCapabilities(params.capabilities ?? [])
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get id() {
|
|
50
|
+
return this._params.id;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get name() {
|
|
54
|
+
return this._params.name;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get capabilities() {
|
|
58
|
+
return this._params.capabilities;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get parameters() {
|
|
62
|
+
return this._params;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
tool<ConfigType extends {}, AuthType extends {}>(
|
|
66
|
+
spec: SlateSpecification<ConfigType, AuthType>,
|
|
67
|
+
params: SlateActionParameters
|
|
68
|
+
) {
|
|
69
|
+
return SlateTool.create(spec, this.linkAction(params));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
publicTool<ConfigType extends {}, AuthType extends {}>(
|
|
73
|
+
spec: SlateSpecification<ConfigType, AuthType>,
|
|
74
|
+
params: SlateActionParameters
|
|
75
|
+
) {
|
|
76
|
+
return SlatePublicTool.create(spec, this.linkAction(params));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
trigger<ConfigType extends {}, AuthType extends {}>(
|
|
80
|
+
spec: SlateSpecification<ConfigType, AuthType>,
|
|
81
|
+
params: SlateActionParameters
|
|
82
|
+
) {
|
|
83
|
+
return SlateTrigger.create(spec, this.linkAction(params));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
register<ConfigType extends {}, AuthType extends {}>(params: {
|
|
87
|
+
tools: SlateTool<ConfigType, AuthType, any, any>[];
|
|
88
|
+
triggers: SlateTrigger<ConfigType, AuthType, any, any>[];
|
|
89
|
+
}): SlateAdapter<ConfigType, AuthType> {
|
|
90
|
+
let actions = [...params.tools, ...params.triggers];
|
|
91
|
+
let seenKeys = new Set<string>();
|
|
92
|
+
|
|
93
|
+
for (let action of actions) {
|
|
94
|
+
if (action.adapter && action.adapter !== this.id) {
|
|
95
|
+
throw new SlateDeclarationError(
|
|
96
|
+
`Action "${action.key}" is linked to adapter "${action.adapter}", not "${this.id}"`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!action.adapter) {
|
|
101
|
+
throw new SlateDeclarationError(
|
|
102
|
+
`Action "${action.key}" must be created with this adapter's tool(), publicTool(), or trigger() helpers`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (seenKeys.has(action.key)) {
|
|
107
|
+
throw new SlateDeclarationError(
|
|
108
|
+
`Adapter "${this.id}" already has an action with key "${action.key}"`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
seenKeys.add(action.key);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return new SlateAdapter(this, params.tools, params.triggers);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private linkAction(params: SlateActionParameters): SlateActionParameters {
|
|
119
|
+
if (params.adapter != null && params.adapter !== this.id) {
|
|
120
|
+
throw new SlateDeclarationError(
|
|
121
|
+
`Action "${params.key}" cannot be linked to adapter "${this.id}" because it is already linked to "${params.adapter}"`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
...params,
|
|
127
|
+
adapter: this.id
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export let adapterSpec = (params: SlateAdapterSpecParameters) =>
|
|
133
|
+
SlateAdapterSpec.create(params);
|
|
134
|
+
|
|
135
|
+
let validateCapabilities = (capabilities: SlateAdapterCapability[]) => {
|
|
136
|
+
let seenIds = new Set<string>();
|
|
137
|
+
|
|
138
|
+
return capabilities.map(capability => {
|
|
139
|
+
let id = capability.id?.trim();
|
|
140
|
+
if (!id) {
|
|
141
|
+
throw new SlateDeclarationError('Adapter capability ids must be non-empty strings');
|
|
142
|
+
}
|
|
143
|
+
if (seenIds.has(id)) {
|
|
144
|
+
throw new SlateDeclarationError(`Adapter capability "${id}" is defined more than once`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
seenIds.add(id);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
id,
|
|
151
|
+
value: capability.value
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
};
|
package/src/auth/index.ts
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { ServiceError } from '@lowerdeck/error';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import type { ApiServiceError } from '../error/api';
|
|
4
|
+
import { getOAuthExpiresAtFromExpiresIn, normalizeOAuthTokenResponse } from './oauth';
|
|
5
|
+
|
|
6
|
+
describe('OAuth auth helpers', () => {
|
|
7
|
+
it('converts expires_in seconds to an ISO expiresAt timestamp', () => {
|
|
8
|
+
expect(getOAuthExpiresAtFromExpiresIn(60, { nowMs: 0 })).toBe('1970-01-01T00:01:00.000Z');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('normalizes token responses and preserves omitted refresh tokens', () => {
|
|
12
|
+
expect(
|
|
13
|
+
normalizeOAuthTokenResponse(
|
|
14
|
+
{
|
|
15
|
+
access_token: 'access-token',
|
|
16
|
+
expires_in: '120'
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
nowMs: 0,
|
|
20
|
+
previousRefreshToken: 'existing-refresh-token'
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
).toEqual({
|
|
24
|
+
token: 'access-token',
|
|
25
|
+
refreshToken: 'existing-refresh-token',
|
|
26
|
+
expiresAt: '1970-01-01T00:02:00.000Z'
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('supports falsy refresh-token fallback semantics', () => {
|
|
31
|
+
expect(
|
|
32
|
+
normalizeOAuthTokenResponse(
|
|
33
|
+
{
|
|
34
|
+
access_token: 'access-token',
|
|
35
|
+
refresh_token: ''
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
previousRefreshToken: 'existing-refresh-token',
|
|
39
|
+
refreshTokenFallbackMode: 'falsy'
|
|
40
|
+
}
|
|
41
|
+
).refreshToken
|
|
42
|
+
).toBe('existing-refresh-token');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('throws ServiceError for missing access tokens', () => {
|
|
46
|
+
let call = () =>
|
|
47
|
+
normalizeOAuthTokenResponse(
|
|
48
|
+
{
|
|
49
|
+
expires_in: 120
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
providerLabel: 'Example',
|
|
53
|
+
operation: 'token exchange'
|
|
54
|
+
}
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(call).toThrow(ServiceError);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
call();
|
|
61
|
+
} catch (error) {
|
|
62
|
+
expect(error).toBeInstanceOf(ServiceError);
|
|
63
|
+
expect((error as ApiServiceError).data.reason).toBe('oauth_token_response');
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('can require a numeric expires_in value', () => {
|
|
68
|
+
expect(() =>
|
|
69
|
+
normalizeOAuthTokenResponse(
|
|
70
|
+
{
|
|
71
|
+
access_token: 'access-token',
|
|
72
|
+
expires_in: '120'
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
providerLabel: 'Example',
|
|
76
|
+
operation: 'token exchange',
|
|
77
|
+
required: true,
|
|
78
|
+
expiresInType: 'number'
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
).toThrow(ServiceError);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -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
|
+
};
|