@thorprovider/medusa-extended 1.11.7 → 1.11.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thorprovider/medusa-extended",
3
- "version": "1.11.7",
3
+ "version": "1.11.8",
4
4
  "description": "Thor Commerce multi-tenant admin extensions for Medusa v2",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@thorprovider/adapters": "^5.0.8",
28
- "@thorprovider/types": "^5.3.6"
28
+ "@thorprovider/types": "^5.3.8"
29
29
  },
30
30
  "devDependencies": {
31
31
  "msw": "^2.12.3",
package/src/index.ts CHANGED
@@ -9,3 +9,6 @@ export type { MedusaAdminClientConfig } from './admin';
9
9
  export { DropshipperClient } from './dropshipper';
10
10
  export type { DropshipperClientConfig, ChatStreamEvent } from './dropshipper';
11
11
 
12
+ export { RecoveryClient } from './recovery';
13
+ export type { RecoveryClientConfig } from './recovery';
14
+
@@ -0,0 +1,247 @@
1
+ /**
2
+ * @fileoverview Recovery Client — Thor Commerce Extended
3
+ * @module @thorprovider/medusa-extended/recovery
4
+ *
5
+ * Public HTTP client for account-recovery flows (password reset + activation).
6
+ *
7
+ * Unlike `DropshipperClient`, this client does NOT require a JWT: it talks to
8
+ * public storefront endpoints so a user can recover access without being
9
+ * logged in. Backend URLs are owned here; the dashboard never builds them.
10
+ *
11
+ * Endpoints:
12
+ * - `POST /auth/user/emailpass/reset-password` (request reset, Medusa native)
13
+ * - `GET /store/dropshipper/auth/reset-password/verify` (verify reset token, no consume)
14
+ * - `POST /auth/user/emailpass/update` (confirm reset, consumes token)
15
+ * - `GET /store/dropshipper/applications/activate/verify`(verify activation token, no consume)
16
+ * - `POST /store/dropshipper/applications/activate` (confirm activation, consumes token)
17
+ *
18
+ * Confirm methods follow the two-phase contract: verify (no consume) first,
19
+ * then confirm (atomic consume). A re-verify after a failed confirm maps the
20
+ * TOCTOU state (used/expired/revoked) into `RecoveryConfirmResult.status`.
21
+ */
22
+
23
+ import type { TokenVerifyResult, RecoveryConfirmResult } from '@thorprovider/types';
24
+ import { createLogger, ProviderAPIError } from '@thorprovider/adapters';
25
+ import { fetchBackendWithResilience } from './request-resilience';
26
+
27
+ type FetchMethod = 'GET' | 'POST';
28
+
29
+ /**
30
+ * Configuration required to instantiate the recovery client.
31
+ */
32
+ export interface RecoveryClientConfig {
33
+ /** Medusa backend base URL */
34
+ baseUrl: string;
35
+ /** Enable debug logging */
36
+ debug?: boolean;
37
+ }
38
+
39
+ /**
40
+ * HTTP client for account-recovery endpoints (public, no auth required).
41
+ *
42
+ * All methods throw `ProviderAPIError` on non-2xx responses except the
43
+ * confirm methods, which normalize failures into `RecoveryConfirmResult`.
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * const client = new RecoveryClient({
48
+ * baseUrl: process.env.MEDUSA_BACKEND_URL!,
49
+ * });
50
+ *
51
+ * await client.requestUserPasswordReset('juan@ejemplo.com');
52
+ * const result = await client.verifyUserPasswordResetToken(token);
53
+ * const confirm = await client.confirmUserPasswordReset(token, 'NuevaClave1');
54
+ * ```
55
+ */
56
+ export class RecoveryClient {
57
+ private baseUrl: string;
58
+ private logger: ReturnType<typeof createLogger>;
59
+
60
+ constructor(config: RecoveryClientConfig) {
61
+ this.baseUrl = config.baseUrl.replace(/\/$/, '');
62
+ this.logger = createLogger('RecoveryClient', config.debug);
63
+ }
64
+
65
+ // -------------------------------------------------------------------------
66
+ // Private — HTTP helper
67
+ // -------------------------------------------------------------------------
68
+
69
+ private async request<T>(method: FetchMethod, path: string, body?: unknown): Promise<T> {
70
+ const url = `${this.baseUrl}${path}`;
71
+ this.logger.debug(`[recovery] ${method} ${path}`);
72
+
73
+ const headers: Record<string, string> = {
74
+ 'Content-Type': 'application/json',
75
+ };
76
+
77
+ const init: RequestInit = {
78
+ method,
79
+ headers,
80
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
81
+ };
82
+
83
+ let response: Response;
84
+ try {
85
+ response = await fetchBackendWithResilience(url, init);
86
+ } catch (networkError: any) {
87
+ throw new ProviderAPIError(
88
+ `[recovery] Network error on ${method} ${path}: ${networkError.message}`,
89
+ 'NETWORK_ERROR',
90
+ networkError,
91
+ );
92
+ }
93
+
94
+ if (!response.ok) {
95
+ let message = `HTTP ${response.status}`;
96
+ try {
97
+ const json = await response.json();
98
+ message = json?.message ?? message;
99
+ } catch {
100
+ // ignore parse errors
101
+ }
102
+ throw new ProviderAPIError(
103
+ `[recovery] ${method} ${path} failed: ${message}`,
104
+ `RECOVERY_API_${response.status}`,
105
+ response.status,
106
+ );
107
+ }
108
+
109
+ return response.json() as Promise<T>;
110
+ }
111
+
112
+ // -------------------------------------------------------------------------
113
+ // Private — reset token helpers
114
+ // -------------------------------------------------------------------------
115
+
116
+ /**
117
+ * Decodes the `entity_id` (email) claim from a Medusa reset-password JWT.
118
+ *
119
+ * The JWT is unsigned-trust for this claim: the email is later sent to
120
+ * `POST /auth/user/emailpass/update`, where Medusa itself re-validates the
121
+ * token signature and payload before applying the change.
122
+ */
123
+ private decodeResetTokenEmail(token: string): string | null {
124
+ const parts = token.split('.');
125
+ if (parts.length !== 3) return null;
126
+ try {
127
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
128
+ return typeof payload.entity_id === 'string' && payload.entity_id.length > 0
129
+ ? payload.entity_id
130
+ : null;
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ // -------------------------------------------------------------------------
137
+ // Password reset
138
+ // -------------------------------------------------------------------------
139
+
140
+ /**
141
+ * Solicita un email de reset de contraseña para el actor `user`
142
+ * (dropshippers). El backend responde de forma uniforme exista o no la
143
+ * cuenta (anti enumeración).
144
+ *
145
+ * `POST /auth/user/emailpass/reset-password`
146
+ */
147
+ async requestUserPasswordReset(email: string): Promise<void> {
148
+ await this.request('POST', '/auth/user/emailpass/reset-password', {
149
+ identifier: email,
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Verifica un token de reset de contraseña sin consumirlo. Devuelve el
155
+ * email enmascarado (p. ej. `j***@ejemplo.com`) solo cuando `status === 'valid'`.
156
+ *
157
+ * `GET /store/dropshipper/auth/reset-password/verify`
158
+ */
159
+ async verifyUserPasswordResetToken(token: string): Promise<TokenVerifyResult> {
160
+ return this.request('GET', `/store/dropshipper/auth/reset-password/verify?token=${encodeURIComponent(token)}`);
161
+ }
162
+
163
+ /**
164
+ * Confirma el reset: re-verifica el token (sin consumir), luego consume el
165
+ * token vía `POST /auth/user/emailpass/update`. El email se deriva del
166
+ * propio JWT. Nunca devuelve auto-login: al éxito, la UI redirige a /login.
167
+ */
168
+ async confirmUserPasswordReset(token: string, password: string): Promise<RecoveryConfirmResult> {
169
+ const verify = await this.verifyUserPasswordResetToken(token);
170
+ if (verify.status !== 'valid') {
171
+ return { ok: false, status: verify.status };
172
+ }
173
+
174
+ const email = this.decodeResetTokenEmail(token);
175
+ if (!email) {
176
+ return { ok: false, status: 'invalid' };
177
+ }
178
+
179
+ try {
180
+ const headers: Record<string, string> = {
181
+ Authorization: `Bearer ${token}`,
182
+ 'Content-Type': 'application/json',
183
+ };
184
+ const response = await fetchBackendWithResilience(`${this.baseUrl}/auth/user/emailpass/update`, {
185
+ method: 'POST',
186
+ headers,
187
+ body: JSON.stringify({ email, password }),
188
+ });
189
+ if (!response.ok) {
190
+ throw new ProviderAPIError(
191
+ `[recovery] POST /auth/user/emailpass/update failed: HTTP ${response.status}`,
192
+ 'RECOVERY_API_' + response.status,
193
+ response.status,
194
+ );
195
+ }
196
+ return { ok: true };
197
+ } catch (error) {
198
+ // TOCTOU: el token pudo consumirse/expirar entre verify y update.
199
+ this.logger.warn('[recovery] confirmUserPasswordReset failed, re-verifying', error);
200
+ try {
201
+ const recheck = await this.verifyUserPasswordResetToken(token);
202
+ return { ok: false, status: recheck.status };
203
+ } catch {
204
+ return { ok: false, status: 'invalid' };
205
+ }
206
+ }
207
+ }
208
+
209
+ // -------------------------------------------------------------------------
210
+ // Account activation
211
+ // -------------------------------------------------------------------------
212
+
213
+ /**
214
+ * Verifica un token de activación de cuenta dropshipper sin consumirlo.
215
+ *
216
+ * `GET /store/dropshipper/applications/activate/verify`
217
+ */
218
+ async verifyActivationToken(token: string): Promise<TokenVerifyResult> {
219
+ return this.request('GET', `/store/dropshipper/applications/activate/verify?token=${encodeURIComponent(token)}`);
220
+ }
221
+
222
+ /**
223
+ * Activa la cuenta: re-verifica el token (sin consumir), luego consume el
224
+ * token vía `POST /store/dropshipper/applications/activate` y establece la
225
+ * contraseña inicial. Al éxito, la UI redirige a /login sin auto-login.
226
+ */
227
+ async activateAccount(token: string, password: string): Promise<RecoveryConfirmResult> {
228
+ const verify = await this.verifyActivationToken(token);
229
+ if (verify.status !== 'valid') {
230
+ return { ok: false, status: verify.status };
231
+ }
232
+
233
+ try {
234
+ await this.request('POST', '/store/dropshipper/applications/activate', { token, password });
235
+ return { ok: true };
236
+ } catch (error) {
237
+ // TOCTOU: re-verificar para distinguir used/expired/revoked.
238
+ this.logger.warn('[recovery] activateAccount failed, re-verifying', error);
239
+ try {
240
+ const recheck = await this.verifyActivationToken(token);
241
+ return { ok: false, status: recheck.status };
242
+ } catch {
243
+ return { ok: false, status: 'invalid' };
244
+ }
245
+ }
246
+ }
247
+ }