@thorprovider/adapters 4.2.1 → 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.
- package/PROVIDER_IMPLEMENTATION_ANALYSIS.md +659 -0
- package/dist/index.d.mts +160 -1
- package/dist/index.d.ts +160 -1
- package/dist/index.js +384 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +384 -2
- package/dist/index.mjs.map +1 -1
- package/docs/RETRY_SOLUTION_SUMMARY.md +26 -11
- package/docs/authentication.md +115 -62
- package/docs/capabilities-system.md +4 -2
- package/docs/category-customization.md +37 -29
- package/docs/fetch-reliability-solution.md +127 -278
- package/docs/getting-started.md +43 -29
- package/docs/profile-management.md +68 -38
- package/docs/services-overview.md +4 -2
- package/package.json +1 -1
- package/quick-reference.md +33 -0
- package/src/providers/medusa/admin.test.ts +404 -0
- package/src/providers/medusa/admin.ts +442 -0
- package/src/providers/medusa/auth.test.ts +137 -0
- package/src/providers/medusa/auth.ts +96 -1
- package/src/providers/medusa/index.ts +21 -1
- package/src/providers/shopify/auth.ts +6 -0
- package/src/resilience/README.md +70 -48
- package/src/core/errors.js +0 -161
- package/src/core/fetch-utils.js +0 -240
- package/src/core/logger.js +0 -63
- package/src/metadata/channel-scope-validation.js +0 -136
- package/src/metadata/channel-scope.js +0 -174
- package/src/metadata/index.js +0 -31
- package/src/providers/medusa/auth.js +0 -601
- package/src/providers/medusa/index.js +0 -2485
- package/src/providers/medusa/payment.js +0 -265
- package/src/providers/medusa/transforms.js +0 -505
- package/src/types/capabilities.types.js +0 -141
- package/src/types/provider.js +0 -9
- package/src/utils/payment-utils.js +0 -152
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Medusa Multi-Tenant Admin Client
|
|
3
|
+
* @module @thorprovider/adapters/providers/medusa/admin
|
|
4
|
+
*
|
|
5
|
+
* Wrapper methods for all `/admin/thor/` endpoints defined in the Thor Commerce
|
|
6
|
+
* multi-tenant API contract. Requires an admin API key (`adminConfig.apiKey`)
|
|
7
|
+
* to be provided when creating the Medusa provider.
|
|
8
|
+
*
|
|
9
|
+
* Every method performs a raw `fetch` against the Medusa backend using the
|
|
10
|
+
* admin API key (`x-medusa-access-token`) — the Medusa JS-SDK's admin client
|
|
11
|
+
* does not expose these custom Thor plugin routes, so we use direct HTTP calls.
|
|
12
|
+
*
|
|
13
|
+
* Endpoint reference: `multitenant-api-contract.md`
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
GetChannelCustomersOptions,
|
|
18
|
+
GetChannelCustomersResponse,
|
|
19
|
+
GetChannelApiKeysResponse,
|
|
20
|
+
GetChannelCategoriesOptions,
|
|
21
|
+
GetChannelCategoriesResponse,
|
|
22
|
+
GetCategorySalesChannelsResponse,
|
|
23
|
+
AssignCategoriesToChannelsBody,
|
|
24
|
+
AssignCategoriesToChannelsResponse,
|
|
25
|
+
UnassignCategoriesFromChannelsBody,
|
|
26
|
+
UnassignCategoriesFromChannelsResponse,
|
|
27
|
+
GetCollectionSalesChannelsResponse,
|
|
28
|
+
AssignCollectionsToChannelsBody,
|
|
29
|
+
AssignCollectionsToChannelsResponse,
|
|
30
|
+
UnassignCollectionsFromChannelsBody,
|
|
31
|
+
UnassignCollectionsFromChannelsResponse,
|
|
32
|
+
GetAdminStorefrontConfigResponse,
|
|
33
|
+
UpdateStorefrontConfigBody,
|
|
34
|
+
UpdateStorefrontConfigResponse,
|
|
35
|
+
GetAdminSiteConfigResponse,
|
|
36
|
+
UpdateSiteConfigBody,
|
|
37
|
+
UpdateSiteConfigResponse,
|
|
38
|
+
GetAdminSiteConfigHistoryOptions,
|
|
39
|
+
GetAdminSiteConfigHistoryResponse,
|
|
40
|
+
RestoreSiteConfigResponse,
|
|
41
|
+
} from '@thorprovider/types';
|
|
42
|
+
import { createLogger } from '../../core/logger';
|
|
43
|
+
import { ProviderAPIError } from '../../core/errors';
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Internal helpers
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
type FetchMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Configuration required to instantiate the admin client.
|
|
53
|
+
*/
|
|
54
|
+
export interface MedusaAdminClientConfig {
|
|
55
|
+
/** Medusa backend base URL */
|
|
56
|
+
baseUrl: string;
|
|
57
|
+
/** Secret admin API key (x-medusa-access-token) */
|
|
58
|
+
apiKey: string;
|
|
59
|
+
/** Enable debug logging */
|
|
60
|
+
debug?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// MedusaAdminClient
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* HTTP client for Thor Commerce multi-tenant admin endpoints (`/admin/thor/`).
|
|
69
|
+
*
|
|
70
|
+
* All methods throw `ProviderAPIError` on non-2xx responses.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```typescript
|
|
74
|
+
* const admin = new MedusaAdminClient({
|
|
75
|
+
* baseUrl: process.env.MEDUSA_BACKEND_URL!,
|
|
76
|
+
* apiKey: process.env.MEDUSA_ADMIN_API_KEY!,
|
|
77
|
+
* });
|
|
78
|
+
*
|
|
79
|
+
* const { customers } = await admin.getChannelCustomers('sc_01JXXXXX');
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export class MedusaAdminClient {
|
|
83
|
+
private baseUrl: string;
|
|
84
|
+
private apiKey: string;
|
|
85
|
+
private logger: ReturnType<typeof createLogger>;
|
|
86
|
+
|
|
87
|
+
constructor(config: MedusaAdminClientConfig) {
|
|
88
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
|
89
|
+
this.apiKey = config.apiKey;
|
|
90
|
+
this.logger = createLogger('MedusaAdminClient', config.debug);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// -----------------------------------------------------------------------
|
|
94
|
+
// Private — HTTP helper
|
|
95
|
+
// -----------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
private async request<T>(
|
|
98
|
+
method: FetchMethod,
|
|
99
|
+
path: string,
|
|
100
|
+
body?: unknown,
|
|
101
|
+
): Promise<T> {
|
|
102
|
+
const url = `${this.baseUrl}${path}`;
|
|
103
|
+
this.logger.debug(`[admin] ${method} ${path}`);
|
|
104
|
+
|
|
105
|
+
const headers: Record<string, string> = {
|
|
106
|
+
'x-medusa-access-token': this.apiKey,
|
|
107
|
+
'Content-Type': 'application/json',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const init: RequestInit = {
|
|
111
|
+
method,
|
|
112
|
+
headers,
|
|
113
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
let response: Response;
|
|
117
|
+
try {
|
|
118
|
+
response = await fetch(url, init);
|
|
119
|
+
} catch (networkError: any) {
|
|
120
|
+
throw new ProviderAPIError(
|
|
121
|
+
`[admin] Network error on ${method} ${path}: ${networkError.message}`,
|
|
122
|
+
'NETWORK_ERROR',
|
|
123
|
+
networkError,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
let message = `HTTP ${response.status}`;
|
|
129
|
+
try {
|
|
130
|
+
const json = await response.json();
|
|
131
|
+
message = json?.message ?? message;
|
|
132
|
+
} catch {
|
|
133
|
+
// ignore parse errors
|
|
134
|
+
}
|
|
135
|
+
throw new ProviderAPIError(
|
|
136
|
+
`[admin] ${method} ${path} failed: ${message}`,
|
|
137
|
+
`ADMIN_API_${response.status}`,
|
|
138
|
+
response.status,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return response.json() as Promise<T>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// -----------------------------------------------------------------------
|
|
146
|
+
// 3.1 — Sales Channel: Customers
|
|
147
|
+
// -----------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Paginated list of customers linked to a sales channel.
|
|
151
|
+
*
|
|
152
|
+
* `GET /admin/thor/sales-channels/:id/customers`
|
|
153
|
+
*/
|
|
154
|
+
async getChannelCustomers(
|
|
155
|
+
salesChannelId: string,
|
|
156
|
+
options: GetChannelCustomersOptions = {},
|
|
157
|
+
): Promise<GetChannelCustomersResponse> {
|
|
158
|
+
const { limit = 20, offset = 0 } = options;
|
|
159
|
+
return this.request<GetChannelCustomersResponse>(
|
|
160
|
+
'GET',
|
|
161
|
+
`/admin/thor/sales-channels/${salesChannelId}/customers?limit=${limit}&offset=${offset}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// -----------------------------------------------------------------------
|
|
166
|
+
// 3.2 — Sales Channel: API Keys
|
|
167
|
+
// -----------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Publishable API keys associated with a sales channel.
|
|
171
|
+
*
|
|
172
|
+
* `GET /admin/thor/sales-channels/:id/api-keys`
|
|
173
|
+
*/
|
|
174
|
+
async getChannelApiKeys(salesChannelId: string): Promise<GetChannelApiKeysResponse> {
|
|
175
|
+
return this.request<GetChannelApiKeysResponse>(
|
|
176
|
+
'GET',
|
|
177
|
+
`/admin/thor/sales-channels/${salesChannelId}/api-keys`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// -----------------------------------------------------------------------
|
|
182
|
+
// 3.3 — Sales Channel: Categories
|
|
183
|
+
// -----------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Product categories assigned to a sales channel.
|
|
187
|
+
*
|
|
188
|
+
* `GET /admin/thor/sales-channels/:id/categories`
|
|
189
|
+
*/
|
|
190
|
+
async getChannelCategories(
|
|
191
|
+
salesChannelId: string,
|
|
192
|
+
options: GetChannelCategoriesOptions = {},
|
|
193
|
+
): Promise<GetChannelCategoriesResponse> {
|
|
194
|
+
const { limit = 20, offset = 0, include_descendants = false } = options;
|
|
195
|
+
const qs = new URLSearchParams({
|
|
196
|
+
limit: String(limit),
|
|
197
|
+
offset: String(offset),
|
|
198
|
+
include_descendants: String(include_descendants),
|
|
199
|
+
});
|
|
200
|
+
return this.request<GetChannelCategoriesResponse>(
|
|
201
|
+
'GET',
|
|
202
|
+
`/admin/thor/sales-channels/${salesChannelId}/categories?${qs}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// -----------------------------------------------------------------------
|
|
207
|
+
// 3.4 — Category: Sales Channels
|
|
208
|
+
// -----------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Sales channels to which a category is assigned.
|
|
212
|
+
*
|
|
213
|
+
* `GET /admin/thor/categories/:id/sales-channels`
|
|
214
|
+
*/
|
|
215
|
+
async getCategorySalesChannels(
|
|
216
|
+
categoryId: string,
|
|
217
|
+
): Promise<GetCategorySalesChannelsResponse> {
|
|
218
|
+
return this.request<GetCategorySalesChannelsResponse>(
|
|
219
|
+
'GET',
|
|
220
|
+
`/admin/thor/categories/${categoryId}/sales-channels`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// -----------------------------------------------------------------------
|
|
225
|
+
// 3.5 — Category: Assign to Sales Channels
|
|
226
|
+
// -----------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Assign a category to one or more sales channels (idempotent).
|
|
230
|
+
*
|
|
231
|
+
* `POST /admin/thor/categories/:id/sales-channels`
|
|
232
|
+
*/
|
|
233
|
+
async assignCategoryToChannels(
|
|
234
|
+
categoryId: string,
|
|
235
|
+
body: AssignCategoriesToChannelsBody,
|
|
236
|
+
): Promise<AssignCategoriesToChannelsResponse> {
|
|
237
|
+
return this.request<AssignCategoriesToChannelsResponse>(
|
|
238
|
+
'POST',
|
|
239
|
+
`/admin/thor/categories/${categoryId}/sales-channels`,
|
|
240
|
+
body,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// -----------------------------------------------------------------------
|
|
245
|
+
// 3.6 — Category: Unassign from Sales Channels
|
|
246
|
+
// -----------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Remove category assignment from one or more sales channels (idempotent).
|
|
250
|
+
*
|
|
251
|
+
* `DELETE /admin/thor/categories/:id/sales-channels`
|
|
252
|
+
*/
|
|
253
|
+
async unassignCategoryFromChannels(
|
|
254
|
+
categoryId: string,
|
|
255
|
+
body: UnassignCategoriesFromChannelsBody,
|
|
256
|
+
): Promise<UnassignCategoriesFromChannelsResponse> {
|
|
257
|
+
return this.request<UnassignCategoriesFromChannelsResponse>(
|
|
258
|
+
'DELETE',
|
|
259
|
+
`/admin/thor/categories/${categoryId}/sales-channels`,
|
|
260
|
+
body,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// -----------------------------------------------------------------------
|
|
265
|
+
// 3.7 — Collection: Sales Channels
|
|
266
|
+
// -----------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Sales channels to which a collection is assigned.
|
|
270
|
+
*
|
|
271
|
+
* `GET /admin/thor/collections/:id/sales-channels`
|
|
272
|
+
*/
|
|
273
|
+
async getCollectionSalesChannels(
|
|
274
|
+
collectionId: string,
|
|
275
|
+
): Promise<GetCollectionSalesChannelsResponse> {
|
|
276
|
+
return this.request<GetCollectionSalesChannelsResponse>(
|
|
277
|
+
'GET',
|
|
278
|
+
`/admin/thor/collections/${collectionId}/sales-channels`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// -----------------------------------------------------------------------
|
|
283
|
+
// 3.8 — Collection: Assign to Sales Channels
|
|
284
|
+
// -----------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Assign a collection to one or more sales channels (idempotent).
|
|
288
|
+
*
|
|
289
|
+
* `POST /admin/thor/collections/:id/sales-channels`
|
|
290
|
+
*/
|
|
291
|
+
async assignCollectionToChannels(
|
|
292
|
+
collectionId: string,
|
|
293
|
+
body: AssignCollectionsToChannelsBody,
|
|
294
|
+
): Promise<AssignCollectionsToChannelsResponse> {
|
|
295
|
+
return this.request<AssignCollectionsToChannelsResponse>(
|
|
296
|
+
'POST',
|
|
297
|
+
`/admin/thor/collections/${collectionId}/sales-channels`,
|
|
298
|
+
body,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// -----------------------------------------------------------------------
|
|
303
|
+
// 3.9 — Collection: Unassign from Sales Channels
|
|
304
|
+
// -----------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Remove collection assignment from one or more sales channels (idempotent).
|
|
308
|
+
*
|
|
309
|
+
* `DELETE /admin/thor/collections/:id/sales-channels`
|
|
310
|
+
*/
|
|
311
|
+
async unassignCollectionFromChannels(
|
|
312
|
+
collectionId: string,
|
|
313
|
+
body: UnassignCollectionsFromChannelsBody,
|
|
314
|
+
): Promise<UnassignCollectionsFromChannelsResponse> {
|
|
315
|
+
return this.request<UnassignCollectionsFromChannelsResponse>(
|
|
316
|
+
'DELETE',
|
|
317
|
+
`/admin/thor/collections/${collectionId}/sales-channels`,
|
|
318
|
+
body,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// -----------------------------------------------------------------------
|
|
323
|
+
// 3.10 — Storefront Config: Read
|
|
324
|
+
// -----------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Read the visual storefront configuration for a channel.
|
|
328
|
+
* `:id` is the `sales_channel_id`.
|
|
329
|
+
*
|
|
330
|
+
* `GET /admin/thor/storefront-config/:id`
|
|
331
|
+
*/
|
|
332
|
+
async getStorefrontConfig(
|
|
333
|
+
salesChannelId: string,
|
|
334
|
+
): Promise<GetAdminStorefrontConfigResponse> {
|
|
335
|
+
return this.request<GetAdminStorefrontConfigResponse>(
|
|
336
|
+
'GET',
|
|
337
|
+
`/admin/thor/storefront-config/${salesChannelId}`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// -----------------------------------------------------------------------
|
|
342
|
+
// 3.11 — Storefront Config: Update
|
|
343
|
+
// -----------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Update the visual storefront configuration for a channel.
|
|
347
|
+
* Triggers the `storefront_config.updated` event → ISR webhook.
|
|
348
|
+
*
|
|
349
|
+
* `PUT /admin/thor/storefront-config/:id`
|
|
350
|
+
*/
|
|
351
|
+
async updateStorefrontConfig(
|
|
352
|
+
salesChannelId: string,
|
|
353
|
+
body: UpdateStorefrontConfigBody,
|
|
354
|
+
): Promise<UpdateStorefrontConfigResponse> {
|
|
355
|
+
return this.request<UpdateStorefrontConfigResponse>(
|
|
356
|
+
'PUT',
|
|
357
|
+
`/admin/thor/storefront-config/${salesChannelId}`,
|
|
358
|
+
body,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// -----------------------------------------------------------------------
|
|
363
|
+
// 3.12 — Site Designer Config: Read
|
|
364
|
+
// -----------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Read the active Site Designer configuration for a channel, including the
|
|
368
|
+
* last 10 history entries.
|
|
369
|
+
*
|
|
370
|
+
* `GET /admin/thor/site-config/:sales_channel_id`
|
|
371
|
+
*/
|
|
372
|
+
async getSiteConfig(salesChannelId: string): Promise<GetAdminSiteConfigResponse> {
|
|
373
|
+
return this.request<GetAdminSiteConfigResponse>(
|
|
374
|
+
'GET',
|
|
375
|
+
`/admin/thor/site-config/${salesChannelId}`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// -----------------------------------------------------------------------
|
|
380
|
+
// 3.13 — Site Designer Config: Update
|
|
381
|
+
// -----------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Update the Site Designer configuration for a channel.
|
|
385
|
+
* Creates a new history entry on every PUT.
|
|
386
|
+
*
|
|
387
|
+
* `PUT /admin/thor/site-config/:sales_channel_id`
|
|
388
|
+
*/
|
|
389
|
+
async updateSiteConfig(
|
|
390
|
+
salesChannelId: string,
|
|
391
|
+
body: UpdateSiteConfigBody,
|
|
392
|
+
): Promise<UpdateSiteConfigResponse> {
|
|
393
|
+
return this.request<UpdateSiteConfigResponse>(
|
|
394
|
+
'PUT',
|
|
395
|
+
`/admin/thor/site-config/${salesChannelId}`,
|
|
396
|
+
body,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// -----------------------------------------------------------------------
|
|
401
|
+
// 3.14 — Site Designer Config: History
|
|
402
|
+
// -----------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Full paginated history of Site Designer versions for a channel.
|
|
406
|
+
* Entries are immutable — they are never deleted.
|
|
407
|
+
*
|
|
408
|
+
* `GET /admin/thor/site-config/:sales_channel_id/history`
|
|
409
|
+
*/
|
|
410
|
+
async getSiteConfigHistory(
|
|
411
|
+
salesChannelId: string,
|
|
412
|
+
options: GetAdminSiteConfigHistoryOptions = {},
|
|
413
|
+
): Promise<GetAdminSiteConfigHistoryResponse> {
|
|
414
|
+
const { limit = 20, offset = 0 } = options;
|
|
415
|
+
return this.request<GetAdminSiteConfigHistoryResponse>(
|
|
416
|
+
'GET',
|
|
417
|
+
`/admin/thor/site-config/${salesChannelId}/history?limit=${limit}&offset=${offset}`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// -----------------------------------------------------------------------
|
|
422
|
+
// 3.15 — Site Designer Config: Restore
|
|
423
|
+
// -----------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Restore a previous Site Designer version.
|
|
427
|
+
* Internally runs the update workflow with the stored config of the target
|
|
428
|
+
* version — this creates a **new** history entry and does NOT mutate past
|
|
429
|
+
* entries.
|
|
430
|
+
*
|
|
431
|
+
* `POST /admin/thor/site-config/:sales_channel_id/restore/:version`
|
|
432
|
+
*/
|
|
433
|
+
async restoreSiteConfig(
|
|
434
|
+
salesChannelId: string,
|
|
435
|
+
version: string,
|
|
436
|
+
): Promise<RestoreSiteConfigResponse> {
|
|
437
|
+
return this.request<RestoreSiteConfigResponse>(
|
|
438
|
+
'POST',
|
|
439
|
+
`/admin/thor/site-config/${salesChannelId}/restore/${encodeURIComponent(version)}`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Tests for MedusaAuthProvider.linkCustomerToChannel
|
|
3
|
+
*
|
|
4
|
+
* Uses msw to intercept fetch, a minimal Medusa SDK stub, and a custom
|
|
5
|
+
* storage stub to inject a pre-set JWT token.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from 'vitest'
|
|
9
|
+
import { http, HttpResponse } from 'msw'
|
|
10
|
+
import { server } from '../stelorder/client.test-helpers'
|
|
11
|
+
import { MedusaAuthProvider } from './auth'
|
|
12
|
+
|
|
13
|
+
const BASE = 'https://medusa.example.com'
|
|
14
|
+
const PUB_KEY = 'pk_test_123'
|
|
15
|
+
const TOKEN = 'jwt-bearer-token'
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Minimal SDK stub — we only test the linkCustomerToChannel raw-fetch path,
|
|
19
|
+
// so the SDK itself does not need to do anything.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
function makeSdkStub(): any {
|
|
23
|
+
return {
|
|
24
|
+
auth: {},
|
|
25
|
+
store: { customer: {} },
|
|
26
|
+
} as any
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Custom storage stub — provides a pre-set token to the auth provider
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
function makeStorageWithToken(token: string | null) {
|
|
34
|
+
let stored: string | null = token
|
|
35
|
+
return {
|
|
36
|
+
getItem: (_key: string) => Promise.resolve(stored),
|
|
37
|
+
setItem: (_key: string, value: string) => { stored = value; return Promise.resolve() },
|
|
38
|
+
removeItem: (_key: string) => { stored = null; return Promise.resolve() },
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeAuthConfig(token: string | null = TOKEN) {
|
|
43
|
+
return {
|
|
44
|
+
method: 'jwt' as const,
|
|
45
|
+
storage: 'memory' as const,
|
|
46
|
+
customStorage: makeStorageWithToken(token),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function makeProvider(token: string | null = TOKEN) {
|
|
51
|
+
return new MedusaAuthProvider(makeSdkStub(), makeAuthConfig(token), false, BASE, PUB_KEY)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Tests
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
describe('MedusaAuthProvider.linkCustomerToChannel', () => {
|
|
59
|
+
it('POST /store/thor/customers/me/channels with Authorization header when token present', async () => {
|
|
60
|
+
server.use(
|
|
61
|
+
http.post(`${BASE}/store/thor/customers/me/channels`, ({ request }) => {
|
|
62
|
+
expect(request.headers.get('x-publishable-api-key')).toBe(PUB_KEY)
|
|
63
|
+
expect(request.headers.get('Authorization')).toBe(`Bearer ${TOKEN}`)
|
|
64
|
+
return HttpResponse.json({ linked: true })
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const result = await makeProvider().linkCustomerToChannel()
|
|
69
|
+
expect(result.linked).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('POST without Authorization header when no token in storage', async () => {
|
|
73
|
+
server.use(
|
|
74
|
+
http.post(`${BASE}/store/thor/customers/me/channels`, ({ request }) => {
|
|
75
|
+
expect(request.headers.get('Authorization')).toBeNull()
|
|
76
|
+
expect(request.headers.get('x-publishable-api-key')).toBe(PUB_KEY)
|
|
77
|
+
return HttpResponse.json({ linked: true })
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const result = await makeProvider(null).linkCustomerToChannel()
|
|
82
|
+
expect(result.linked).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('returns { linked: true, reason: "already_linked" } on duplicate link', async () => {
|
|
86
|
+
server.use(
|
|
87
|
+
http.post(`${BASE}/store/thor/customers/me/channels`, () =>
|
|
88
|
+
HttpResponse.json({ linked: true, reason: 'already_linked' }),
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const result = await makeProvider().linkCustomerToChannel()
|
|
93
|
+
expect(result.linked).toBe(true)
|
|
94
|
+
expect(result.reason).toBe('already_linked')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('throws ProviderAPIError with NOT_AUTHENTICATED on 401', async () => {
|
|
98
|
+
server.use(
|
|
99
|
+
http.post(`${BASE}/store/thor/customers/me/channels`, () =>
|
|
100
|
+
HttpResponse.json({ message: 'Unauthorized' }, { status: 401 }),
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
await expect(makeProvider().linkCustomerToChannel()).rejects.toMatchObject({
|
|
105
|
+
name: 'ProviderAPIError',
|
|
106
|
+
provider: 'NOT_AUTHENTICATED',
|
|
107
|
+
status: 401,
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('throws ProviderAPIError with CHANNEL_RESOLUTION_FAILED on 400', async () => {
|
|
112
|
+
server.use(
|
|
113
|
+
http.post(`${BASE}/store/thor/customers/me/channels`, () =>
|
|
114
|
+
HttpResponse.json({ message: 'No sales channel found for publishable key' }, { status: 400 }),
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
await expect(makeProvider().linkCustomerToChannel()).rejects.toMatchObject({
|
|
119
|
+
name: 'ProviderAPIError',
|
|
120
|
+
provider: 'CHANNEL_RESOLUTION_FAILED',
|
|
121
|
+
status: 400,
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('throws ProviderAPIError with LINK_CHANNEL_FAILED on unexpected status', async () => {
|
|
126
|
+
server.use(
|
|
127
|
+
http.post(`${BASE}/store/thor/customers/me/channels`, () =>
|
|
128
|
+
HttpResponse.json({ message: 'Service unavailable' }, { status: 503 }),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
await expect(makeProvider().linkCustomerToChannel()).rejects.toMatchObject({
|
|
133
|
+
name: 'ProviderAPIError',
|
|
134
|
+
provider: 'LINK_CHANNEL_FAILED',
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
CreateAddressData,
|
|
20
20
|
PasswordResetRequest,
|
|
21
21
|
PasswordResetConfirm,
|
|
22
|
+
LinkCustomerToChannelResponse,
|
|
22
23
|
} from '@thorprovider/types';
|
|
23
24
|
import { createLogger } from '../../core/logger';
|
|
24
25
|
import { ProviderAPIError } from '../../core/errors';
|
|
@@ -120,12 +121,18 @@ export class MedusaAuthProvider implements AuthProvider {
|
|
|
120
121
|
private logger: ReturnType<typeof createLogger>;
|
|
121
122
|
private authConfig: AuthConfig;
|
|
122
123
|
private storageManager: AuthStorageManager;
|
|
124
|
+
/** Medusa backend base URL — needed for raw fetch calls */
|
|
125
|
+
private baseUrl: string;
|
|
126
|
+
/** Publishable API key — sent as x-publishable-api-key on store calls */
|
|
127
|
+
private publishableKey: string;
|
|
123
128
|
|
|
124
|
-
constructor(sdk: Medusa, authConfig: AuthConfig, debug = false) {
|
|
129
|
+
constructor(sdk: Medusa, authConfig: AuthConfig, debug = false, baseUrl = '', publishableKey = '') {
|
|
125
130
|
this.sdk = sdk;
|
|
126
131
|
this.authConfig = authConfig;
|
|
127
132
|
this.logger = createLogger('MedusaAuthProvider', debug);
|
|
128
133
|
this.storageManager = new AuthStorageManager(authConfig);
|
|
134
|
+
this.baseUrl = baseUrl;
|
|
135
|
+
this.publishableKey = publishableKey;
|
|
129
136
|
|
|
130
137
|
this.logger.info('Medusa Auth Provider initialized', {
|
|
131
138
|
method: authConfig.method,
|
|
@@ -493,4 +500,92 @@ export class MedusaAuthProvider implements AuthProvider {
|
|
|
493
500
|
undefined
|
|
494
501
|
);
|
|
495
502
|
}
|
|
503
|
+
|
|
504
|
+
// ========================================
|
|
505
|
+
// Multi-Tenant — Channel Link
|
|
506
|
+
// ========================================
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Link the currently authenticated customer to the storefront's sales channel.
|
|
510
|
+
*
|
|
511
|
+
* Must be called immediately after a successful `login()` so the customer
|
|
512
|
+
* appears in the admin panel of the correct storefront channel.
|
|
513
|
+
*
|
|
514
|
+
* Implements `POST /store/thor/customers/me/channels`.
|
|
515
|
+
* Idempotent — returns `{ linked: false, reason: 'already_linked' }` when
|
|
516
|
+
* the link already exists.
|
|
517
|
+
*
|
|
518
|
+
* @throws {ProviderAPIError} When not authenticated (401) or channel cannot
|
|
519
|
+
* be resolved from the publishable key (400).
|
|
520
|
+
*/
|
|
521
|
+
async linkCustomerToChannel(): Promise<LinkCustomerToChannelResponse> {
|
|
522
|
+
this.logger.debug('linkCustomerToChannel');
|
|
523
|
+
|
|
524
|
+
if (!this.baseUrl || !this.publishableKey) {
|
|
525
|
+
throw new ProviderAPIError(
|
|
526
|
+
'linkCustomerToChannel requires baseUrl and publishableKey to be set on the auth provider.',
|
|
527
|
+
'CONFIGURATION_ERROR',
|
|
528
|
+
undefined
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Retrieve the JWT token for the Authorization header.
|
|
533
|
+
// Falls back to the SDK-managed token (session or JWT storage).
|
|
534
|
+
const token = await this.storageManager.getToken();
|
|
535
|
+
|
|
536
|
+
const headers: Record<string, string> = {
|
|
537
|
+
'x-publishable-api-key': this.publishableKey,
|
|
538
|
+
'Content-Type': 'application/json',
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
if (token) {
|
|
542
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
const url = `${this.baseUrl}/store/thor/customers/me/channels`;
|
|
547
|
+
const response = await fetch(url, {
|
|
548
|
+
method: 'POST',
|
|
549
|
+
headers,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
if (response.status === 401) {
|
|
553
|
+
throw new ProviderAPIError(
|
|
554
|
+
'linkCustomerToChannel: not authenticated. Call login() first.',
|
|
555
|
+
'NOT_AUTHENTICATED',
|
|
556
|
+
401
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (response.status === 400) {
|
|
561
|
+
const body = await response.json().catch(() => ({}));
|
|
562
|
+
throw new ProviderAPIError(
|
|
563
|
+
`linkCustomerToChannel: could not resolve sales channel from publishable key. ${body?.message ?? ''}`.trim(),
|
|
564
|
+
'CHANNEL_RESOLUTION_FAILED',
|
|
565
|
+
400
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (!response.ok) {
|
|
570
|
+
const body = await response.json().catch(() => ({}));
|
|
571
|
+
throw new ProviderAPIError(
|
|
572
|
+
`linkCustomerToChannel failed with status ${response.status}: ${body?.message ?? 'Unknown error'}`,
|
|
573
|
+
'LINK_CHANNEL_FAILED',
|
|
574
|
+
response.status
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const data = await response.json() as LinkCustomerToChannelResponse;
|
|
579
|
+
this.logger.info('Customer linked to channel', { linked: data.linked, reason: data.reason });
|
|
580
|
+
return data;
|
|
581
|
+
} catch (error: any) {
|
|
582
|
+
if (error instanceof ProviderAPIError) throw error;
|
|
583
|
+
this.logger.error('linkCustomerToChannel failed', error);
|
|
584
|
+
throw new ProviderAPIError(
|
|
585
|
+
`linkCustomerToChannel failed: ${error.message}`,
|
|
586
|
+
'LINK_CHANNEL_FAILED',
|
|
587
|
+
error
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
496
591
|
}
|