@slates/provider 1.0.0-rc.15 → 1.0.0-rc.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,8 @@ import {
8
8
  type SlateTriggerPollingHandler,
9
9
  type SlateTriggerWebhookAutoRegistrationHandler,
10
10
  type SlateTriggerWebhookAutoUnregistrationHandler,
11
- type SlateTriggerWebhookRequestHandler
11
+ type SlateTriggerWebhookRequestHandler,
12
+ type SlateWebhookHttpOptions
12
13
  } from './action';
13
14
  import { SlateActionBuilder } from './builder';
14
15
 
@@ -27,6 +28,7 @@ export interface SlateTriggerParameters<
27
28
  polling?: SlatePollingOptions;
28
29
  handleEvent: SlateTriggerMappingHandler<ConfigType, AuthType, InputType, OutputType>;
29
30
  handleRequest?: SlateTriggerWebhookRequestHandler<ConfigType, AuthType, InputType>;
31
+ http?: SlateWebhookHttpOptions;
30
32
  pollEvents?: SlateTriggerPollingHandler<ConfigType, AuthType, InputType>;
31
33
  autoRegisterWebhook?: SlateTriggerWebhookAutoRegistrationHandler<ConfigType, AuthType>;
32
34
  autoUnregisterWebhook?: SlateTriggerWebhookAutoUnregistrationHandler<ConfigType, AuthType>;
@@ -44,6 +46,7 @@ export class SlateTrigger<
44
46
  #polling: SlatePollingOptions;
45
47
  #handleEvent: SlateTriggerMappingHandler<ConfigType, AuthType, InputType, OutputType>;
46
48
  #handleRequest?: SlateTriggerWebhookRequestHandler<ConfigType, AuthType, InputType>;
49
+ #http?: SlateWebhookHttpOptions;
47
50
  #pollEvents?: SlateTriggerPollingHandler<ConfigType, AuthType, InputType>;
48
51
  #autoRegisterWebhook?: SlateTriggerWebhookAutoRegistrationHandler<ConfigType, AuthType>;
49
52
  #autoUnregisterWebhook?: SlateTriggerWebhookAutoUnregistrationHandler<ConfigType, AuthType>;
@@ -62,6 +65,7 @@ export class SlateTrigger<
62
65
  };
63
66
  this.#handleEvent = params.handleEvent;
64
67
  this.#handleRequest = params.handleRequest;
68
+ this.#http = params.http;
65
69
  this.#pollEvents = params.pollEvents;
66
70
  this.#autoRegisterWebhook = params.autoRegisterWebhook;
67
71
  this.#autoUnregisterWebhook = params.autoUnregisterWebhook;
@@ -93,6 +97,10 @@ export class SlateTrigger<
93
97
  return this.#handleRequest;
94
98
  }
95
99
 
100
+ get http() {
101
+ return this.#http;
102
+ }
103
+
96
104
  get pollEvents() {
97
105
  return this.#pollEvents;
98
106
  }
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
+ };
package/src/auth/types.ts CHANGED
@@ -97,7 +97,10 @@ export type SlateAuthWithToken<InputType extends {}, OutputType extends { token?
97
97
 
98
98
  docs?: SlateAuthDocsReference[];
99
99
 
100
- getOutput: (ctx: { input: InputType }) => Promise<{ output: OutputType }>;
100
+ getOutput: (ctx: { input: InputType; config?: SlateAuthCallbackConfig }) => Promise<{
101
+ output: OutputType;
102
+ scopes?: string[];
103
+ }>;
101
104
 
102
105
  onInputChanged?: (params: {
103
106
  previousInput: InputType;
@@ -106,7 +109,12 @@ export type SlateAuthWithToken<InputType extends {}, OutputType extends { token?
106
109
 
107
110
  getDefaultInput?: () => Promise<InputType>;
108
111
 
109
- getProfile?: (ctx: { output: OutputType; input: InputType }) => Promise<{
112
+ getProfile?: (ctx: {
113
+ output: OutputType;
114
+ input: InputType;
115
+ scopes: string[];
116
+ config?: SlateAuthCallbackConfig;
117
+ }) => Promise<{
110
118
  profile: Record<string, any>;
111
119
  }>;
112
120
  };
@@ -121,7 +129,10 @@ export type SlateAuthWithServiceAccount<InputType extends {}, OutputType extends
121
129
 
122
130
  docs?: SlateAuthDocsReference[];
123
131
 
124
- getOutput: (ctx: { input: InputType }) => Promise<{ output: OutputType }>;
132
+ getOutput: (ctx: { input: InputType; config?: SlateAuthCallbackConfig }) => Promise<{
133
+ output: OutputType;
134
+ scopes?: string[];
135
+ }>;
125
136
 
126
137
  onInputChanged?: (params: {
127
138
  previousInput: InputType;
@@ -130,7 +141,12 @@ export type SlateAuthWithServiceAccount<InputType extends {}, OutputType extends
130
141
 
131
142
  getDefaultInput?: () => Promise<InputType>;
132
143
 
133
- getProfile?: (ctx: { output: OutputType; input: InputType }) => Promise<{
144
+ getProfile?: (ctx: {
145
+ output: OutputType;
146
+ input: InputType;
147
+ scopes: string[];
148
+ config?: SlateAuthCallbackConfig;
149
+ }) => Promise<{
134
150
  profile: Record<string, any>;
135
151
  }>;
136
152
  };
@@ -145,7 +161,10 @@ export type SlateAuthWithCustomData<InputType extends {}, OutputType extends {}>
145
161
 
146
162
  docs?: SlateAuthDocsReference[];
147
163
 
148
- getOutput: (ctx: { input: InputType }) => Promise<{ output: OutputType }>;
164
+ getOutput: (ctx: { input: InputType; config?: SlateAuthCallbackConfig }) => Promise<{
165
+ output: OutputType;
166
+ scopes?: string[];
167
+ }>;
149
168
 
150
169
  handleTokenRefresh?: (ctx: {
151
170
  output: OutputType;
@@ -153,6 +172,7 @@ export type SlateAuthWithCustomData<InputType extends {}, OutputType extends {}>
153
172
  clientId: string;
154
173
  clientSecret: string;
155
174
  scopes: string[];
175
+ config?: SlateAuthCallbackConfig;
156
176
  }) => Promise<{
157
177
  output: OutputType;
158
178
  input?: InputType;
@@ -165,7 +185,12 @@ export type SlateAuthWithCustomData<InputType extends {}, OutputType extends {}>
165
185
 
166
186
  getDefaultInput?: () => Promise<InputType>;
167
187
 
168
- getProfile?: (ctx: { output: OutputType; input: InputType }) => Promise<{
188
+ getProfile?: (ctx: {
189
+ output: OutputType;
190
+ input: InputType;
191
+ scopes: string[];
192
+ config?: SlateAuthCallbackConfig;
193
+ }) => Promise<{
169
194
  profile: Record<string, any>;
170
195
  }>;
171
196
  };
@@ -0,0 +1,83 @@
1
+ import type { AxiosResponse } from 'axios';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createAuthenticatedAxios, requestAxios, requestAxiosData } from './index';
4
+
5
+ describe('createAuthenticatedAxios', () => {
6
+ it('sets auth and JSON headers while preserving caller headers', () => {
7
+ let client = createAuthenticatedAxios({
8
+ baseURL: 'https://example.test',
9
+ authHeader: {
10
+ value: 'Bearer token'
11
+ },
12
+ headers: {
13
+ Accept: 'application/json'
14
+ }
15
+ });
16
+
17
+ expect(client.defaults.baseURL).toBe('https://example.test');
18
+ expect(client.defaults.headers.Authorization).toBe('Bearer token');
19
+ expect(client.defaults.headers['Content-Type']).toBe('application/json');
20
+ expect(client.defaults.headers.Accept).toBe('application/json');
21
+ });
22
+
23
+ it('allows custom auth header names and disabling content type defaults', () => {
24
+ let client = createAuthenticatedAxios({
25
+ authHeader: {
26
+ name: 'X-API-Key',
27
+ value: 'secret'
28
+ },
29
+ contentType: false
30
+ });
31
+
32
+ expect(client.defaults.headers['X-API-Key']).toBe('secret');
33
+ expect(client.defaults.headers['Content-Type']).toBeUndefined();
34
+ });
35
+ });
36
+
37
+ describe('requestAxios helpers', () => {
38
+ it('returns full responses or response data', async () => {
39
+ let response = {
40
+ config: {} as AxiosResponse<{ ok: boolean }>['config'],
41
+ data: { ok: true },
42
+ headers: {},
43
+ status: 200,
44
+ statusText: 'OK'
45
+ };
46
+
47
+ await expect(
48
+ requestAxios(
49
+ 'get thing',
50
+ async () => response,
51
+ error => error
52
+ )
53
+ ).resolves.toBe(response);
54
+ await expect(
55
+ requestAxiosData(
56
+ 'get thing',
57
+ async () => response,
58
+ error => error
59
+ )
60
+ ).resolves.toEqual({ ok: true });
61
+ });
62
+
63
+ it('maps failures with the operation name', async () => {
64
+ let upstream = new Error('upstream failed');
65
+ let mapped = new Error('mapped failure');
66
+ let seenOperation: string | undefined;
67
+
68
+ await expect(
69
+ requestAxiosData(
70
+ 'create thing',
71
+ async () => {
72
+ throw upstream;
73
+ },
74
+ (_error, operation) => {
75
+ seenOperation = operation;
76
+ return mapped;
77
+ }
78
+ )
79
+ ).rejects.toBe(mapped);
80
+
81
+ expect(seenOperation).toBe('create thing');
82
+ });
83
+ });
@@ -1,4 +1,9 @@
1
- import type { AxiosInstance, CreateAxiosDefaults, InternalAxiosRequestConfig } from 'axios';
1
+ import type {
2
+ AxiosInstance,
3
+ AxiosResponse,
4
+ CreateAxiosDefaults,
5
+ InternalAxiosRequestConfig
6
+ } from 'axios';
2
7
  import baseAxios, { getAdapter, isAxiosError } from 'axios';
3
8
  import { getCurrentContext } from '../context/hook';
4
9
  import type { SlateAxiosErrorOptions } from '../error';
@@ -13,6 +18,17 @@ export interface SlateAxiosDefaults extends CreateAxiosDefaults {
13
18
  errorMapping?: SlateAxiosErrorOptions;
14
19
  }
15
20
 
21
+ export type SlateAxiosErrorAdapter = (error: unknown, operation: string) => unknown;
22
+
23
+ export interface SlateAuthenticatedAxiosDefaults extends SlateAxiosDefaults {
24
+ authHeader?: {
25
+ name?: string;
26
+ value: string;
27
+ };
28
+ contentType?: string | false;
29
+ errorAdapter?: (error: unknown) => unknown;
30
+ }
31
+
16
32
  let applySlateInterceptors = (
17
33
  instance: AxiosInstance,
18
34
  errorMapping?: SlateAxiosErrorOptions
@@ -73,4 +89,46 @@ export let createAxios = (config?: SlateAxiosDefaults) => {
73
89
  return instance;
74
90
  };
75
91
 
92
+ export let createAuthenticatedAxios = (config: SlateAuthenticatedAxiosDefaults) => {
93
+ let { authHeader, contentType = 'application/json', errorAdapter, ...axiosConfig } = config;
94
+ let instance = createAxios({
95
+ ...axiosConfig,
96
+ headers: {
97
+ ...(contentType === false ? {} : { 'Content-Type': contentType }),
98
+ ...(authHeader ? { [authHeader.name ?? 'Authorization']: authHeader.value } : {}),
99
+ ...axiosConfig.headers
100
+ }
101
+ });
102
+
103
+ if (errorAdapter) {
104
+ instance.interceptors.response.use(
105
+ response => response,
106
+ error => Promise.reject(errorAdapter(error))
107
+ );
108
+ }
109
+
110
+ return instance;
111
+ };
112
+
113
+ export let requestAxios = async <T = unknown>(
114
+ operation: string,
115
+ request: () => Promise<AxiosResponse<T>>,
116
+ errorAdapter: SlateAxiosErrorAdapter
117
+ ) => {
118
+ try {
119
+ return await request();
120
+ } catch (error) {
121
+ throw errorAdapter(error, operation);
122
+ }
123
+ };
124
+
125
+ export let requestAxiosData = async <T = unknown>(
126
+ operation: string,
127
+ request: () => Promise<AxiosResponse<T>>,
128
+ errorAdapter: SlateAxiosErrorAdapter
129
+ ) => {
130
+ let response = await requestAxios(operation, request, errorAdapter);
131
+ return response.data as T;
132
+ };
133
+
76
134
  export let axios = createAxios();
@@ -48,6 +48,14 @@ export class SlateContext<ConfigType extends {}, AuthType extends {}, InputType
48
48
  : never;
49
49
  }
50
50
 
51
+ get registrationDetails() {
52
+ return Object.freeze(
53
+ (this.#input as any).registrationDetails
54
+ ) as 'registrationDetails' extends keyof InputType
55
+ ? InputType['registrationDetails']
56
+ : never;
57
+ }
58
+
51
59
  get auth() {
52
60
  return Object.freeze(this.#auth);
53
61
  }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createHmacSignature, verifyHmacSignature } from './hmac';
3
+
4
+ describe('createHmacSignature', () => {
5
+ it('creates prefixed hexadecimal HMAC signatures', () => {
6
+ expect(
7
+ createHmacSignature({
8
+ secret: 'secret',
9
+ payload: 'payload',
10
+ digest: 'hex',
11
+ prefix: 'v0='
12
+ })
13
+ ).toBe('v0=b82fcb791acec57859b989b430a826488ce2e479fdf92326bd0a2e8375a42ba4');
14
+ });
15
+ });
16
+
17
+ describe('verifyHmacSignature', () => {
18
+ it('verifies prefixed hexadecimal HMAC signatures', () => {
19
+ expect(
20
+ verifyHmacSignature({
21
+ secret: 'secret',
22
+ payload: 'payload',
23
+ signature: 'v0=b82fcb791acec57859b989b430a826488ce2e479fdf92326bd0a2e8375a42ba4',
24
+ digest: 'hex',
25
+ prefix: 'v0='
26
+ })
27
+ ).toBe(true);
28
+ });
29
+
30
+ it('rejects incorrect signatures, including signatures of a different length', () => {
31
+ expect(
32
+ verifyHmacSignature({
33
+ secret: 'secret',
34
+ payload: 'payload',
35
+ signature: 'invalid',
36
+ digest: 'base64'
37
+ })
38
+ ).toBe(false);
39
+ });
40
+ });
@@ -0,0 +1,32 @@
1
+ import { createHmac, timingSafeEqual } from 'crypto';
2
+
3
+ export interface CreateHmacSignatureOptions {
4
+ secret: string | Uint8Array;
5
+ payload: string | Uint8Array;
6
+ algorithm?: string;
7
+ digest: 'hex' | 'base64';
8
+ prefix?: string;
9
+ }
10
+
11
+ export interface VerifyHmacSignatureOptions extends CreateHmacSignatureOptions {
12
+ signature: string;
13
+ }
14
+
15
+ export let createHmacSignature = ({
16
+ secret,
17
+ payload,
18
+ algorithm = 'sha256',
19
+ digest,
20
+ prefix = ''
21
+ }: CreateHmacSignatureOptions) =>
22
+ `${prefix}${createHmac(algorithm, secret).update(payload).digest(digest)}`;
23
+
24
+ export let verifyHmacSignature = (options: VerifyHmacSignatureOptions) => {
25
+ let expected = createHmacSignature(options);
26
+ let expectedBytes = Buffer.from(expected);
27
+ let signatureBytes = Buffer.from(options.signature);
28
+ let lengthsMatch = signatureBytes.length === expectedBytes.length;
29
+ let comparableSignature = lengthsMatch ? signatureBytes : Buffer.alloc(expectedBytes.length);
30
+
31
+ return timingSafeEqual(comparableSignature, expectedBytes) && lengthsMatch;
32
+ };
@@ -0,0 +1 @@
1
+ export * from './hmac';