@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.
Files changed (42) hide show
  1. package/dist/index.cjs +639 -21
  2. package/dist/index.d.cts +290 -29
  3. package/dist/index.d.ts +290 -29
  4. package/dist/index.module.js +606 -18
  5. package/package.json +2 -2
  6. package/src/action/action.ts +64 -2
  7. package/src/action/builder.test.ts +136 -0
  8. package/src/action/builder.ts +73 -13
  9. package/src/action/index.ts +2 -1
  10. package/src/action/publicTool.ts +36 -0
  11. package/src/action/tool.ts +15 -3
  12. package/src/action/trigger.ts +9 -1
  13. package/src/adapter/adapter.ts +38 -0
  14. package/src/adapter/index.test.ts +322 -0
  15. package/src/adapter/index.ts +2 -0
  16. package/src/adapter/spec.ts +154 -0
  17. package/src/auth/index.ts +1 -0
  18. package/src/auth/oauth.test.ts +83 -0
  19. package/src/auth/oauth.ts +150 -0
  20. package/src/auth/types.ts +22 -6
  21. package/src/axios/http.test.ts +83 -0
  22. package/src/axios/index.ts +59 -1
  23. package/src/axios/trace.ts +3 -3
  24. package/src/context/context.ts +37 -14
  25. package/src/context/hook.ts +5 -10
  26. package/src/crypto/hmac.test.ts +40 -0
  27. package/src/crypto/hmac.ts +32 -0
  28. package/src/crypto/index.ts +1 -0
  29. package/src/error/api.test.ts +114 -0
  30. package/src/error/api.ts +236 -0
  31. package/src/error/index.ts +1 -0
  32. package/src/file/index.test.ts +36 -0
  33. package/src/file/index.ts +27 -0
  34. package/src/index.ts +5 -0
  35. package/src/object/index.test.ts +38 -0
  36. package/src/object/index.ts +12 -0
  37. package/src/specification/slate.ts +38 -2
  38. package/src/webhook/graph.test.ts +37 -0
  39. package/src/webhook/graph.ts +22 -0
  40. package/src/webhook/index.ts +2 -0
  41. package/src/webhook/meta.test.ts +52 -0
  42. 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,2 @@
1
+ export * from './adapter';
2
+ export * from './spec';
@@ -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
@@ -1,2 +1,3 @@
1
1
  export * from './auth';
2
+ export * from './oauth';
2
3
  export * from './types';
@@ -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
+ };