@slates/provider 1.0.0-rc.2 → 1.0.0-rc.20

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 (124) hide show
  1. package/dist/index.cjs +2847 -2
  2. package/dist/index.d.cts +1019 -0
  3. package/dist/index.d.ts +1019 -9
  4. package/dist/index.module.js +2737 -2
  5. package/package.json +9 -6
  6. package/src/action/action.ts +92 -4
  7. package/src/action/attachment.ts +87 -0
  8. package/src/action/builder.test.ts +136 -0
  9. package/src/action/builder.ts +113 -15
  10. package/src/action/directUpload.ts +59 -0
  11. package/src/action/getFileUrlTool.ts +41 -0
  12. package/src/action/index.ts +4 -2
  13. package/src/action/publicTool.ts +36 -0
  14. package/src/action/scopes.test.ts +76 -0
  15. package/src/action/scopes.ts +103 -0
  16. package/src/action/tool.ts +22 -6
  17. package/src/action/trigger.ts +19 -11
  18. package/src/adapter/adapter.ts +38 -0
  19. package/src/adapter/index.test.ts +322 -0
  20. package/src/adapter/index.ts +2 -0
  21. package/src/adapter/spec.ts +154 -0
  22. package/src/auth/auth.ts +3 -3
  23. package/src/auth/index.ts +2 -0
  24. package/src/auth/oauth.test.ts +83 -0
  25. package/src/auth/oauth.ts +150 -0
  26. package/src/auth/redact.test.ts +103 -0
  27. package/src/auth/redact.ts +111 -0
  28. package/src/auth/types.ts +72 -13
  29. package/src/axios/http.test.ts +83 -0
  30. package/src/axios/index.ts +116 -10
  31. package/src/axios/trace.test.ts +377 -0
  32. package/src/axios/trace.ts +803 -0
  33. package/src/config/config.ts +14 -3
  34. package/src/context/context.ts +264 -17
  35. package/src/context/hook.test.ts +46 -0
  36. package/src/context/hook.ts +15 -6
  37. package/src/context/index.ts +1 -0
  38. package/src/context/pQueue.ts +4 -0
  39. package/src/crypto/hmac.test.ts +40 -0
  40. package/src/crypto/hmac.ts +32 -0
  41. package/src/crypto/index.ts +1 -0
  42. package/src/docs.ts +22 -0
  43. package/src/error/api.test.ts +114 -0
  44. package/src/error/api.ts +236 -0
  45. package/src/error/axios.ts +220 -0
  46. package/src/error/base.test.ts +178 -0
  47. package/src/error/base.ts +108 -2
  48. package/src/error/declaration.ts +7 -2
  49. package/src/error/defaults.ts +50 -0
  50. package/src/error/index.ts +4 -0
  51. package/src/error/service.ts +170 -0
  52. package/src/error/types.ts +75 -0
  53. package/src/error/utils.ts +157 -0
  54. package/src/file/index.test.ts +36 -0
  55. package/src/file/index.ts +27 -0
  56. package/src/index.ts +8 -0
  57. package/src/logger/logger.test.ts +57 -0
  58. package/src/logger/logger.ts +72 -11
  59. package/src/module/resolveDefaultExport.test.ts +32 -0
  60. package/src/module/resolveDefaultExport.ts +14 -0
  61. package/src/object/index.test.ts +38 -0
  62. package/src/object/index.ts +12 -0
  63. package/src/specification/slate.ts +40 -4
  64. package/src/specification/specification.ts +10 -6
  65. package/src/webhook/graph.test.ts +37 -0
  66. package/src/webhook/graph.ts +22 -0
  67. package/src/webhook/index.ts +2 -0
  68. package/src/webhook/meta.test.ts +52 -0
  69. package/src/webhook/meta.ts +43 -0
  70. package/dist/action/action.d.ts +0 -98
  71. package/dist/action/action.d.ts.map +0 -1
  72. package/dist/action/builder.d.ts +0 -27
  73. package/dist/action/builder.d.ts.map +0 -1
  74. package/dist/action/index.d.ts +0 -5
  75. package/dist/action/index.d.ts.map +0 -1
  76. package/dist/action/tool.d.ts +0 -14
  77. package/dist/action/tool.d.ts.map +0 -1
  78. package/dist/action/trigger.d.ts +0 -32
  79. package/dist/action/trigger.d.ts.map +0 -1
  80. package/dist/auth/auth.d.ts +0 -30
  81. package/dist/auth/auth.d.ts.map +0 -1
  82. package/dist/auth/index.d.ts +0 -3
  83. package/dist/auth/index.d.ts.map +0 -1
  84. package/dist/auth/types.d.ts +0 -148
  85. package/dist/auth/types.d.ts.map +0 -1
  86. package/dist/axios/index.d.ts +0 -4
  87. package/dist/axios/index.d.ts.map +0 -1
  88. package/dist/config/config.d.ts +0 -26
  89. package/dist/config/config.d.ts.map +0 -1
  90. package/dist/config/index.d.ts +0 -2
  91. package/dist/config/index.d.ts.map +0 -1
  92. package/dist/context/context.d.ts +0 -20
  93. package/dist/context/context.d.ts.map +0 -1
  94. package/dist/context/hook.d.ts +0 -4
  95. package/dist/context/hook.d.ts.map +0 -1
  96. package/dist/context/index.d.ts +0 -2
  97. package/dist/context/index.d.ts.map +0 -1
  98. package/dist/error/base.d.ts +0 -5
  99. package/dist/error/base.d.ts.map +0 -1
  100. package/dist/error/declaration.d.ts +0 -6
  101. package/dist/error/declaration.d.ts.map +0 -1
  102. package/dist/error/index.d.ts +0 -3
  103. package/dist/error/index.d.ts.map +0 -1
  104. package/dist/index.cjs.map +0 -1
  105. package/dist/index.d.ts.map +0 -1
  106. package/dist/index.modern.js +0 -2
  107. package/dist/index.modern.js.map +0 -1
  108. package/dist/index.module.js.map +0 -1
  109. package/dist/index.umd.js +0 -2
  110. package/dist/index.umd.js.map +0 -1
  111. package/dist/logger/index.d.ts +0 -2
  112. package/dist/logger/index.d.ts.map +0 -1
  113. package/dist/logger/logger.d.ts +0 -20
  114. package/dist/logger/logger.d.ts.map +0 -1
  115. package/dist/specification/index.d.ts +0 -3
  116. package/dist/specification/index.d.ts.map +0 -1
  117. package/dist/specification/slate.d.ts +0 -15
  118. package/dist/specification/slate.d.ts.map +0 -1
  119. package/dist/specification/specification.d.ts +0 -30
  120. package/dist/specification/specification.d.ts.map +0 -1
  121. package/dist/specification/zero.d.ts +0 -13
  122. package/dist/specification/zero.d.ts.map +0 -1
  123. package/dist/tokens.d.ts +0 -34
  124. package/dist/tokens.d.ts.map +0 -1
@@ -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/auth.ts CHANGED
@@ -1,5 +1,5 @@
1
- import z from 'zod';
2
- import { SlateAuthType } from './types';
1
+ import type z from 'zod';
2
+ import type { SlateAuthType } from './types';
3
3
 
4
4
  export class SlateAuth<OutputType extends {} = {}> {
5
5
  #outputSchema: z.ZodType<OutputType> | null = null;
@@ -68,7 +68,7 @@ export class SlateAuth<OutputType extends {} = {}> {
68
68
  }
69
69
 
70
70
  get authStack() {
71
- return this.#authStack.filter(a => a.type != 'auth.none');
71
+ return this.#authStack.filter(a => a.type !== 'auth.none');
72
72
  }
73
73
 
74
74
  get outputSchema() {
package/src/auth/index.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export * from './auth';
2
+ export * from './oauth';
3
+ export * from './redact';
2
4
  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
+ });