@thorprovider/adapters 4.2.0 → 4.3.0

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 (56) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/PROVIDER_IMPLEMENTATION_ANALYSIS.md +659 -0
  3. package/README.md +19 -0
  4. package/dist/index.d.mts +249 -2
  5. package/dist/index.d.ts +249 -2
  6. package/dist/index.js +1450 -2
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +1448 -2
  9. package/dist/index.mjs.map +1 -1
  10. package/docs/RETRY_SOLUTION_SUMMARY.md +26 -11
  11. package/docs/authentication.md +115 -62
  12. package/docs/capabilities-system.md +34 -2
  13. package/docs/category-customization.md +37 -29
  14. package/docs/fetch-reliability-solution.md +127 -278
  15. package/docs/getting-started.md +43 -29
  16. package/docs/profile-management.md +68 -38
  17. package/docs/providers-overview.md +14 -0
  18. package/docs/services-overview.md +20 -2
  19. package/package.json +10 -6
  20. package/quick-reference.md +34 -0
  21. package/src/factory/index.ts +4 -0
  22. package/src/index.ts +3 -0
  23. package/src/providers/index.ts +3 -0
  24. package/src/providers/medusa/admin.test.ts +404 -0
  25. package/src/providers/medusa/admin.ts +442 -0
  26. package/src/providers/medusa/auth.test.ts +137 -0
  27. package/src/providers/medusa/auth.ts +96 -1
  28. package/src/providers/medusa/index.ts +21 -1
  29. package/src/providers/shopify/auth.ts +6 -0
  30. package/src/providers/stelorder/client.test-helpers.ts +3 -0
  31. package/src/providers/stelorder/client.test.ts +58 -0
  32. package/src/providers/stelorder/client.ts +326 -0
  33. package/src/providers/stelorder/index.test.ts +48 -0
  34. package/src/providers/stelorder/index.ts +685 -0
  35. package/src/providers/stelorder/live-smoke.test.ts +54 -0
  36. package/src/providers/stelorder/transforms.test.ts +103 -0
  37. package/src/providers/stelorder/transforms.ts +631 -0
  38. package/src/resilience/README.md +70 -48
  39. package/src/types/capabilities.types.ts +51 -0
  40. package/vitest.config.ts +13 -0
  41. package/vitest.setup.ts +6 -0
  42. package/.turbo/turbo-build.log +0 -18
  43. package/dist/.tsbuildinfo +0 -1
  44. package/src/core/errors.js +0 -161
  45. package/src/core/fetch-utils.js +0 -240
  46. package/src/core/logger.js +0 -63
  47. package/src/metadata/channel-scope-validation.js +0 -136
  48. package/src/metadata/channel-scope.js +0 -174
  49. package/src/metadata/index.js +0 -31
  50. package/src/providers/medusa/auth.js +0 -601
  51. package/src/providers/medusa/index.js +0 -2485
  52. package/src/providers/medusa/payment.js +0 -265
  53. package/src/providers/medusa/transforms.js +0 -505
  54. package/src/types/capabilities.types.js +0 -141
  55. package/src/types/provider.js +0 -9
  56. package/src/utils/payment-utils.js +0 -152
@@ -49,6 +49,7 @@ import { validatePaymentId } from '../../utils/payment-utils';
49
49
  import { transformProduct, transformCart, transformCollection, transformCategory, transformOrder, transformCustomer, transformStockLocation, transformInventoryLevel, transformAddress, transformStorefrontConfig } from './transforms';
50
50
  import { MedusaAuthProvider } from './auth';
51
51
  import { getPaymentMethods } from './payment';
52
+ import { MedusaAdminClient } from './admin';
52
53
  import {
53
54
  buildChannelScopeMetadata,
54
55
  filterByChannel,
@@ -163,6 +164,19 @@ export class MedusaProvider implements CommerceProvider {
163
164
  * Only created when adminConfig.apiKey is provided.
164
165
  */
165
166
  private sdkAdmin?: Medusa;
167
+
168
+ /**
169
+ * Admin client for Thor multi-tenant endpoints (`/admin/thor/`).
170
+ * Available when `adminConfig.apiKey` is provided.
171
+ *
172
+ * @example
173
+ * ```typescript
174
+ * const { customers } = await commerce.admin.getChannelCustomers('sc_01JXXXXX');
175
+ * const { api_keys } = await commerce.admin.getChannelApiKeys('sc_01JXXXXX');
176
+ * await commerce.admin.updateStorefrontConfig('sc_01JXXXXX', { theme_accent_color: '#EF4444', ... });
177
+ * ```
178
+ */
179
+ public readonly admin?: MedusaAdminClient;
166
180
  /** Cached distinct product status values — rarely changes, cached for process lifetime */
167
181
  private _cachedProductStatuses: string[] | null = null;
168
182
 
@@ -253,12 +267,18 @@ export class MedusaProvider implements CommerceProvider {
253
267
  debug: config.debug ?? false,
254
268
  // No publishableKey or auth config: this instance is admin-only, no JWT needed
255
269
  });
270
+ // Admin client for Thor multi-tenant custom endpoints (/admin/thor/)
271
+ (this as any).admin = new MedusaAdminClient({
272
+ baseUrl: config.baseUrl,
273
+ apiKey: config.adminConfig.apiKey,
274
+ debug: config.debug,
275
+ });
256
276
  this.logger.info('[Admin Auth] Dedicated admin SDK instance created with API key');
257
277
  }
258
278
 
259
279
  // Initialize auth provider if config is provided
260
280
  if (config.auth) {
261
- this.auth = new MedusaAuthProvider(this.sdk, config.auth, config.debug);
281
+ this.auth = new MedusaAuthProvider(this.sdk, config.auth, config.debug, config.baseUrl, config.publishableKey);
262
282
  this.logger.info('Auth provider initialized');
263
283
  }
264
284
 
@@ -399,4 +399,10 @@ export class ShopifyAuthProvider implements AuthProvider {
399
399
  async deleteCustomerAccount(): Promise<void> {
400
400
  throw new ProviderAPIError('Shopify customer self-service deletion requires Admin API and is not supported.', 'shopify', 501);
401
401
  }
402
+
403
+ async linkCustomerToChannel(): Promise<{ linked: boolean; reason?: string }> {
404
+ // Shopify does not have the concept of Thor sales channels.
405
+ // Return a no-op response so multi-tenant flows don't break when running against Shopify.
406
+ return { linked: false, reason: 'not_applicable' };
407
+ }
402
408
  }
@@ -0,0 +1,3 @@
1
+ import { setupServer } from 'msw/node'
2
+
3
+ export const server = setupServer()
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { http, HttpResponse } from 'msw'
3
+ import { server } from './client.test-helpers'
4
+ import { StelOrderClient } from './client'
5
+
6
+ describe('StelOrderClient', () => {
7
+ it('normalizes base url and sends APIKEY header', async () => {
8
+ server.use(
9
+ http.get('https://stel.example.com/app/products', ({ request }) => {
10
+ expect(request.headers.get('APIKEY')).toBe('secret')
11
+ expect(request.headers.get('accept')).toBe('application/json')
12
+ return HttpResponse.json([{ id: 1, name: 'P1' }])
13
+ }),
14
+ )
15
+
16
+ const client = new StelOrderClient({
17
+ baseUrl: 'https://stel.example.com/api/openapi.json',
18
+ apiKey: 'secret',
19
+ })
20
+
21
+ const products = await client.list<{ id: number; name: string }>('products')
22
+ expect(products).toEqual([{ id: 1, name: 'P1' }])
23
+ })
24
+
25
+ it('supports create update and delete', async () => {
26
+ server.use(
27
+ http.post('https://stel.example.com/app/clients', async ({ request }) => {
28
+ expect(await request.json()).toEqual({ email: 'a@b.com' })
29
+ return HttpResponse.json({ id: 1, email: 'a@b.com' })
30
+ }),
31
+ http.put('https://stel.example.com/app/clients/1', async ({ request }) => {
32
+ expect(await request.json()).toEqual({ email: 'c@d.com' })
33
+ return HttpResponse.json({ id: 1, email: 'c@d.com' })
34
+ }),
35
+ http.delete('https://stel.example.com/app/clients/1', () => HttpResponse.text('', { status: 204 })),
36
+ )
37
+
38
+ const client = new StelOrderClient({ baseUrl: 'https://stel.example.com', apiKey: 'secret' })
39
+ await expect(client.create('clients', { email: 'a@b.com' })).resolves.toEqual({ id: 1, email: 'a@b.com' })
40
+ await expect(client.update('clients', 1, { email: 'c@d.com' })).resolves.toEqual({ id: 1, email: 'c@d.com' })
41
+ await expect(client.remove('clients', 1)).resolves.toBeUndefined()
42
+ })
43
+
44
+ it('throws provider api error on non-ok responses', async () => {
45
+ server.use(
46
+ http.get('https://stel.example.com/app/products/1', () =>
47
+ HttpResponse.json({ message: 'Not found' }, { status: 404 }),
48
+ ),
49
+ )
50
+
51
+ const client = new StelOrderClient({ baseUrl: 'https://stel.example.com', apiKey: 'secret' })
52
+
53
+ await expect(client.getOne('products', 1)).rejects.toMatchObject({
54
+ name: 'ProviderAPIError',
55
+ status: 404,
56
+ })
57
+ })
58
+ })
@@ -0,0 +1,326 @@
1
+ import type { ProviderConfig } from '../../types/provider';
2
+ import { ConfigurationError, ProviderAPIError, extractErrorMessage } from '../../core/errors';
3
+ import { fetchWithRetry, type RetryConfig } from '../../core/fetch-utils';
4
+ import { createLogger, type Logger } from '../../core/logger';
5
+ import { normalizeStelCollection, normalizeStelEntity } from './transforms';
6
+
7
+ type FetchImplementation = (url: string, init?: RequestInit) => Promise<Response>;
8
+ type HttpMethod = 'DELETE' | 'GET' | 'POST' | 'PUT';
9
+
10
+ export type StelQueryValue =
11
+ | string
12
+ | number
13
+ | boolean
14
+ | null
15
+ | undefined
16
+ | Array<string | number | boolean>;
17
+
18
+ export type StelQueryParams = Record<string, StelQueryValue>;
19
+
20
+ export interface StelOrderProviderConfig extends ProviderConfig {
21
+ baseUrl: string;
22
+ apiKey?: string;
23
+ publishableKey?: string;
24
+ currencyCode?: string;
25
+ companyName?: string;
26
+ storefrontId?: string;
27
+ retry?: RetryConfig;
28
+ }
29
+
30
+ type RequiredRetryConfig = Required<RetryConfig>;
31
+
32
+ const DEFAULT_RETRY_CONFIG: RequiredRetryConfig = {
33
+ maxRetries: 2,
34
+ initialDelayMs: 300,
35
+ backoffMultiplier: 2,
36
+ maxDelayMs: 5000,
37
+ timeoutMs: 10000,
38
+ retryableStatusCodes: [408, 429, 500, 502, 503, 504],
39
+ };
40
+
41
+ function sleep(ms: number): Promise<void> {
42
+ return new Promise((resolve) => setTimeout(resolve, ms));
43
+ }
44
+
45
+ function calculateDelay(attempt: number, retryConfig: RequiredRetryConfig): number {
46
+ const delay = retryConfig.initialDelayMs * Math.pow(retryConfig.backoffMultiplier, attempt);
47
+ return Math.min(delay, retryConfig.maxDelayMs);
48
+ }
49
+
50
+ function isRetryableError(error: unknown, retryConfig: RequiredRetryConfig): boolean {
51
+ if (error instanceof ProviderAPIError) {
52
+ return typeof error.status === 'number' && retryConfig.retryableStatusCodes.includes(error.status);
53
+ }
54
+
55
+ if (!(error instanceof Error)) {
56
+ return false;
57
+ }
58
+
59
+ const message = error.message.toLowerCase();
60
+ return (
61
+ error.name === 'AbortError' ||
62
+ error.name === 'TimeoutError' ||
63
+ message.includes('fetch failed') ||
64
+ message.includes('timeout') ||
65
+ message.includes('econnrefused') ||
66
+ message.includes('etimedout')
67
+ );
68
+ }
69
+
70
+ function createRetryingFetch(
71
+ fetchImplementation: FetchImplementation,
72
+ retryConfig: RequiredRetryConfig,
73
+ logger: Logger,
74
+ ): FetchImplementation {
75
+ return async (url: string, init?: RequestInit): Promise<Response> => {
76
+ let lastError: unknown = new Error('Unknown STEL Order request error');
77
+
78
+ for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt += 1) {
79
+ const controller = new AbortController();
80
+ const timeoutId = setTimeout(() => controller.abort(), retryConfig.timeoutMs);
81
+
82
+ try {
83
+ const response = await fetchImplementation(url, {
84
+ ...init,
85
+ signal: controller.signal,
86
+ });
87
+
88
+ clearTimeout(timeoutId);
89
+
90
+ if (!response.ok && retryConfig.retryableStatusCodes.includes(response.status)) {
91
+ throw new ProviderAPIError(
92
+ `STEL Order API error: ${response.status} ${response.statusText}`,
93
+ 'stelorder',
94
+ response.status,
95
+ );
96
+ }
97
+
98
+ return response;
99
+ } catch (error) {
100
+ clearTimeout(timeoutId);
101
+ lastError = error;
102
+
103
+ if (attempt >= retryConfig.maxRetries || !isRetryableError(error, retryConfig)) {
104
+ throw error;
105
+ }
106
+
107
+ const delay = calculateDelay(attempt, retryConfig);
108
+ logger.warn('STEL request failed, retrying.', {
109
+ url,
110
+ attempt: attempt + 1,
111
+ maxAttempts: retryConfig.maxRetries + 1,
112
+ nextRetryIn: delay,
113
+ error: extractErrorMessage(error),
114
+ });
115
+
116
+ await sleep(delay);
117
+ }
118
+ }
119
+
120
+ throw lastError;
121
+ };
122
+ }
123
+
124
+ async function parseErrorResponse(response: Response): Promise<string> {
125
+ const contentType = response.headers.get('content-type') ?? '';
126
+
127
+ try {
128
+ if (contentType.includes('application/json')) {
129
+ const body = (await response.json()) as unknown;
130
+ const message = extractErrorMessage(body);
131
+ if (message && message !== 'An unknown error occurred') {
132
+ return message;
133
+ }
134
+ }
135
+ } catch {
136
+ // Ignore body parsing issues and fall back to text/status.
137
+ }
138
+
139
+ try {
140
+ const text = await response.text();
141
+ if (text.trim()) {
142
+ return text.trim();
143
+ }
144
+ } catch {
145
+ // Ignore body parsing issues and fall back to status.
146
+ }
147
+
148
+ return `STEL Order API error: ${response.status} ${response.statusText}`;
149
+ }
150
+
151
+ export function normalizeStelApiBaseUrl(baseUrl: string): string {
152
+ const trimmed = baseUrl.trim();
153
+
154
+ if (!trimmed) {
155
+ throw new ConfigurationError('Stel Order baseUrl is required.');
156
+ }
157
+
158
+ const normalized = trimmed
159
+ .replace(/\/api\/openapi\.json$/i, '')
160
+ .replace(/\/+$/, '');
161
+
162
+ return normalized.endsWith('/app') ? normalized : `${normalized}/app`;
163
+ }
164
+
165
+ interface StelRequestOptions {
166
+ method: HttpMethod;
167
+ path: string;
168
+ query?: StelQueryParams;
169
+ body?: unknown;
170
+ headers?: HeadersInit;
171
+ }
172
+
173
+ export class StelOrderClient {
174
+ readonly apiBaseUrl: string;
175
+
176
+ private readonly apiKey: string;
177
+ private readonly logger: Logger;
178
+ private readonly retryConfig: RequiredRetryConfig;
179
+ private readonly fetchImplementation?: FetchImplementation;
180
+
181
+ constructor(config: StelOrderProviderConfig) {
182
+ const apiKey = config.apiKey ?? config.publishableKey;
183
+
184
+ if (!apiKey) {
185
+ throw new ConfigurationError(
186
+ 'Stel Order API key is required. Pass `apiKey` or `publishableKey` to createProvider().',
187
+ );
188
+ }
189
+
190
+ this.apiBaseUrl = normalizeStelApiBaseUrl(config.baseUrl);
191
+ this.apiKey = apiKey;
192
+ this.logger = createLogger('StelOrderClient', config.debug);
193
+ this.retryConfig = {
194
+ ...DEFAULT_RETRY_CONFIG,
195
+ ...config.retry,
196
+ };
197
+ this.fetchImplementation = config.customFetch
198
+ ? createRetryingFetch(config.customFetch, this.retryConfig, this.logger)
199
+ : undefined;
200
+
201
+ this.logger.info('Initializing Stel Order client', {
202
+ apiBaseUrl: this.apiBaseUrl,
203
+ retryConfig: this.retryConfig,
204
+ });
205
+ }
206
+
207
+ async list<T>(resource: string, query?: StelQueryParams): Promise<T[]> {
208
+ const payload = await this.request<T | T[]>({
209
+ method: 'GET',
210
+ path: resource,
211
+ query,
212
+ });
213
+
214
+ return normalizeStelCollection(payload);
215
+ }
216
+
217
+ async getOne<T>(resource: string, id: string | number, query?: StelQueryParams): Promise<T | undefined> {
218
+ const payload = await this.request<T | T[]>({
219
+ method: 'GET',
220
+ path: `${resource}/${id}`,
221
+ query,
222
+ });
223
+
224
+ return normalizeStelEntity(payload);
225
+ }
226
+
227
+ async create<TResponse, TBody = unknown>(resource: string, body: TBody): Promise<TResponse> {
228
+ return this.request<TResponse>({
229
+ method: 'POST',
230
+ path: resource,
231
+ body,
232
+ });
233
+ }
234
+
235
+ async update<TResponse, TBody = unknown>(resource: string, id: string | number, body: TBody): Promise<TResponse> {
236
+ return this.request<TResponse>({
237
+ method: 'PUT',
238
+ path: `${resource}/${id}`,
239
+ body,
240
+ });
241
+ }
242
+
243
+ async remove(resource: string, id: string | number): Promise<void> {
244
+ await this.request<void>({
245
+ method: 'DELETE',
246
+ path: `${resource}/${id}`,
247
+ });
248
+ }
249
+
250
+ private buildUrl(path: string, query?: StelQueryParams): string {
251
+ const normalizedPath = path.replace(/^\/+/, '');
252
+ const url = new URL(`${this.apiBaseUrl}/${normalizedPath}`);
253
+
254
+ if (!query) {
255
+ return url.toString();
256
+ }
257
+
258
+ for (const [key, value] of Object.entries(query)) {
259
+ if (value === undefined || value === null || value === '') {
260
+ continue;
261
+ }
262
+
263
+ if (Array.isArray(value)) {
264
+ url.searchParams.set(key, value.map(String).join(','));
265
+ continue;
266
+ }
267
+
268
+ url.searchParams.set(key, String(value));
269
+ }
270
+
271
+ return url.toString();
272
+ }
273
+
274
+ private buildHeaders(headers?: HeadersInit, hasBody: boolean = false): Headers {
275
+ const normalizedHeaders = new Headers(headers);
276
+
277
+ normalizedHeaders.set('Accept', 'application/json');
278
+ normalizedHeaders.set('APIKEY', this.apiKey);
279
+
280
+ if (hasBody && !normalizedHeaders.has('Content-Type')) {
281
+ normalizedHeaders.set('Content-Type', 'application/json');
282
+ }
283
+
284
+ return normalizedHeaders;
285
+ }
286
+
287
+ private async executeFetch(url: string, init: RequestInit): Promise<Response> {
288
+ if (this.fetchImplementation) {
289
+ return this.fetchImplementation(url, init);
290
+ }
291
+
292
+ return fetchWithRetry(url, init, this.retryConfig);
293
+ }
294
+
295
+ private async request<TResponse>(options: StelRequestOptions): Promise<TResponse> {
296
+ const url = this.buildUrl(options.path, options.query);
297
+ const hasBody = options.body !== undefined;
298
+
299
+ this.logger.debug('STEL request', {
300
+ method: options.method,
301
+ url,
302
+ });
303
+
304
+ const response = await this.executeFetch(url, {
305
+ method: options.method,
306
+ headers: this.buildHeaders(options.headers, hasBody),
307
+ body: hasBody ? JSON.stringify(options.body) : undefined,
308
+ });
309
+
310
+ if (!response.ok) {
311
+ const message = await parseErrorResponse(response);
312
+ throw new ProviderAPIError(message, 'stelorder', response.status);
313
+ }
314
+
315
+ if (response.status === 204) {
316
+ return undefined as TResponse;
317
+ }
318
+
319
+ const contentType = response.headers.get('content-type') ?? '';
320
+ if (contentType.includes('application/json')) {
321
+ return (await response.json()) as TResponse;
322
+ }
323
+
324
+ return (await response.text()) as TResponse;
325
+ }
326
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { http, HttpResponse } from 'msw'
3
+ import { server } from './client.test-helpers'
4
+ import { StelOrderProvider } from './index'
5
+
6
+ describe('StelOrderProvider', () => {
7
+ it('returns storefront context and supports read methods', async () => {
8
+ server.use(
9
+ http.get('https://stel.example.com/app/products', () =>
10
+ HttpResponse.json([{ id: 10, name: 'Test Product', 'full-reference': 'TP-10', 'sales-price': 10 }]),
11
+ ),
12
+ http.get('https://stel.example.com/app/productCategories', () =>
13
+ HttpResponse.json([{ id: 2, name: 'Hardware', slug: 'hardware' }]),
14
+ ),
15
+ )
16
+
17
+ const provider = new StelOrderProvider({
18
+ baseUrl: 'https://stel.example.com',
19
+ apiKey: 'secret',
20
+ companyName: 'Test Co',
21
+ storefrontId: 'stel-1',
22
+ })
23
+
24
+ const context = await provider.getStorefrontContext()
25
+ expect(context.platformType).toBe('stelorder')
26
+ expect(context.name).toBe('Test Co')
27
+
28
+ await expect(provider.getProducts()).resolves.toHaveLength(1)
29
+ await expect(provider.getCategories()).resolves.toHaveLength(1)
30
+ })
31
+
32
+ it('throws clear errors for unsupported workflows', async () => {
33
+ const provider = new StelOrderProvider({
34
+ baseUrl: 'https://stel.example.com',
35
+ apiKey: 'secret',
36
+ })
37
+
38
+ await expect(provider.createCart()).rejects.toMatchObject({
39
+ name: 'ProviderAPIError',
40
+ status: 501,
41
+ })
42
+
43
+ await expect(provider.completeCheckout('cart-1')).rejects.toMatchObject({
44
+ name: 'ProviderAPIError',
45
+ status: 501,
46
+ })
47
+ })
48
+ })