@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/dist/index.d.mts +98 -2
- package/dist/index.d.ts +98 -2
- package/dist/index.js +174 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +172 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/recovery.ts +247 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thorprovider/medusa-extended",
|
|
3
|
-
"version": "1.11.
|
|
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.
|
|
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
|
+
|
package/src/recovery.ts
ADDED
|
@@ -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
|
+
}
|