@slates/provider 1.0.0-rc.16 → 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.
@@ -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,7 @@ export type SlateAuthWithToken<InputType extends {}, OutputType extends { token?
97
97
 
98
98
  docs?: SlateAuthDocsReference[];
99
99
 
100
- getOutput: (ctx: { input: InputType }) => Promise<{
100
+ getOutput: (ctx: { input: InputType; config?: SlateAuthCallbackConfig }) => Promise<{
101
101
  output: OutputType;
102
102
  scopes?: string[];
103
103
  }>;
@@ -109,7 +109,12 @@ export type SlateAuthWithToken<InputType extends {}, OutputType extends { token?
109
109
 
110
110
  getDefaultInput?: () => Promise<InputType>;
111
111
 
112
- getProfile?: (ctx: { output: OutputType; input: InputType }) => Promise<{
112
+ getProfile?: (ctx: {
113
+ output: OutputType;
114
+ input: InputType;
115
+ scopes: string[];
116
+ config?: SlateAuthCallbackConfig;
117
+ }) => Promise<{
113
118
  profile: Record<string, any>;
114
119
  }>;
115
120
  };
@@ -124,7 +129,7 @@ export type SlateAuthWithServiceAccount<InputType extends {}, OutputType extends
124
129
 
125
130
  docs?: SlateAuthDocsReference[];
126
131
 
127
- getOutput: (ctx: { input: InputType }) => Promise<{
132
+ getOutput: (ctx: { input: InputType; config?: SlateAuthCallbackConfig }) => Promise<{
128
133
  output: OutputType;
129
134
  scopes?: string[];
130
135
  }>;
@@ -136,7 +141,12 @@ export type SlateAuthWithServiceAccount<InputType extends {}, OutputType extends
136
141
 
137
142
  getDefaultInput?: () => Promise<InputType>;
138
143
 
139
- getProfile?: (ctx: { output: OutputType; input: InputType }) => Promise<{
144
+ getProfile?: (ctx: {
145
+ output: OutputType;
146
+ input: InputType;
147
+ scopes: string[];
148
+ config?: SlateAuthCallbackConfig;
149
+ }) => Promise<{
140
150
  profile: Record<string, any>;
141
151
  }>;
142
152
  };
@@ -151,7 +161,7 @@ export type SlateAuthWithCustomData<InputType extends {}, OutputType extends {}>
151
161
 
152
162
  docs?: SlateAuthDocsReference[];
153
163
 
154
- getOutput: (ctx: { input: InputType }) => Promise<{
164
+ getOutput: (ctx: { input: InputType; config?: SlateAuthCallbackConfig }) => Promise<{
155
165
  output: OutputType;
156
166
  scopes?: string[];
157
167
  }>;
@@ -162,6 +172,7 @@ export type SlateAuthWithCustomData<InputType extends {}, OutputType extends {}>
162
172
  clientId: string;
163
173
  clientSecret: string;
164
174
  scopes: string[];
175
+ config?: SlateAuthCallbackConfig;
165
176
  }) => Promise<{
166
177
  output: OutputType;
167
178
  input?: InputType;
@@ -174,7 +185,12 @@ export type SlateAuthWithCustomData<InputType extends {}, OutputType extends {}>
174
185
 
175
186
  getDefaultInput?: () => Promise<InputType>;
176
187
 
177
- getProfile?: (ctx: { output: OutputType; input: InputType }) => Promise<{
188
+ getProfile?: (ctx: {
189
+ output: OutputType;
190
+ input: InputType;
191
+ scopes: string[];
192
+ config?: SlateAuthCallbackConfig;
193
+ }) => Promise<{
178
194
  profile: Record<string, any>;
179
195
  }>;
180
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';
@@ -0,0 +1,114 @@
1
+ import { ServiceError } from '@lowerdeck/error';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { buildApiServiceError, collectApiErrorDetails, createApiServiceError } from './api';
4
+ import { SlateError } from './base';
5
+
6
+ describe('API service error helpers', () => {
7
+ it('collects unique nested API error details', () => {
8
+ let details: string[] = [];
9
+
10
+ collectApiErrorDetails(
11
+ {
12
+ message: 'Invalid request',
13
+ code: 'bad_request',
14
+ errors: [
15
+ {
16
+ detail: 'Missing name'
17
+ },
18
+ 'Missing name'
19
+ ]
20
+ },
21
+ details
22
+ );
23
+
24
+ expect(details).toEqual(['Invalid request', 'bad_request', 'Missing name']);
25
+ });
26
+
27
+ it('builds provider API ServiceErrors with upstream metadata', () => {
28
+ let parent = new Error('Request failed');
29
+ (parent as any).response = {
30
+ status: 429,
31
+ statusText: 'Too Many Requests',
32
+ data: {
33
+ title: 'Rate limit',
34
+ details: [{ message: 'Retry later' }]
35
+ }
36
+ };
37
+
38
+ let error = buildApiServiceError(parent, {
39
+ providerLabel: 'Example',
40
+ operation: 'list widgets',
41
+ reason: 'example_api_error',
42
+ detailKeys: ['title', 'message'],
43
+ nestedKeys: ['details'],
44
+ extractUpstreamCode: () => 'RATE_LIMITED'
45
+ });
46
+
47
+ expect(error).toBeInstanceOf(ServiceError);
48
+ expect(error.data.message).toBe(
49
+ 'Example API list widgets failed: HTTP 429 Too Many Requests: Rate limit - Retry later'
50
+ );
51
+ expect(error.data.reason).toBe('example_api_error');
52
+ expect(error.data.upstreamStatus).toBe(429);
53
+ expect(error.data.upstreamCode).toBe('RATE_LIMITED');
54
+ });
55
+
56
+ it('recovers the upstream status from interceptor-normalized SlateErrors', () => {
57
+ // Runtime axios instances reject with SlateError, not AxiosError: the
58
+ // response interceptor consumes error.response, so the status only
59
+ // survives at data.upstream.status / data.status.
60
+ let parent = new SlateError({
61
+ code: 'not_found',
62
+ message: 'Method not found.',
63
+ upstream: { status: 404 }
64
+ });
65
+
66
+ let error = buildApiServiceError(parent, {
67
+ providerLabel: 'Example',
68
+ operation: 'search messages',
69
+ reason: 'example_api_error'
70
+ });
71
+
72
+ expect(error.data.upstreamStatus).toBe(404);
73
+ expect(error.data.message).toBe(
74
+ 'Example API search messages failed: HTTP 404: Method not found.'
75
+ );
76
+ });
77
+
78
+ it('allows provider-specific status and message extraction', () => {
79
+ let error = buildApiServiceError(
80
+ {
81
+ status: 503,
82
+ data: {
83
+ message: 'Unavailable'
84
+ }
85
+ },
86
+ {
87
+ providerLabel: 'Example',
88
+ reason: 'example_api_error',
89
+ extractStatus: (input, _response, helpers) =>
90
+ helpers.isRecord(input) && typeof input.status === 'number'
91
+ ? input.status
92
+ : undefined,
93
+ extractMessage: (input, helpers) => {
94
+ if (!helpers.isRecord(input) || !helpers.isRecord(input.data)) return undefined;
95
+ return typeof input.data.message === 'string' ? input.data.message : undefined;
96
+ }
97
+ }
98
+ );
99
+
100
+ expect(error.data.message).toBe('Example API request failed: HTTP 503: Unavailable');
101
+ expect(error.data.upstreamStatus).toBe(503);
102
+ });
103
+
104
+ it('passes through existing ServiceErrors', () => {
105
+ let existing = createApiServiceError('Already normalized');
106
+
107
+ expect(
108
+ buildApiServiceError(existing, {
109
+ providerLabel: 'Example',
110
+ reason: 'example_api_error'
111
+ })
112
+ ).toBe(existing);
113
+ });
114
+ });