@utilia-os/sdk-js 1.4.0 → 1.7.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/README.md +22 -0
- package/dist/index.d.mts +378 -25
- package/dist/index.d.ts +378 -25
- package/dist/index.js +638 -43
- package/dist/index.mjs +628 -39
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/client.ts
|
|
2
|
-
import
|
|
2
|
+
import axios2, { AxiosError } from "axios";
|
|
3
3
|
|
|
4
4
|
// src/errors/sdk-error.ts
|
|
5
5
|
var ErrorCode = /* @__PURE__ */ ((ErrorCode2) => {
|
|
@@ -9,6 +9,7 @@ var ErrorCode = /* @__PURE__ */ ((ErrorCode2) => {
|
|
|
9
9
|
ErrorCode2["RATE_LIMITED"] = "RATE_LIMITED";
|
|
10
10
|
ErrorCode2["VALIDATION_ERROR"] = "VALIDATION_ERROR";
|
|
11
11
|
ErrorCode2["NETWORK_ERROR"] = "NETWORK_ERROR";
|
|
12
|
+
ErrorCode2["AUTH_EXPIRED"] = "AUTH_EXPIRED";
|
|
12
13
|
ErrorCode2["UNKNOWN"] = "UNKNOWN";
|
|
13
14
|
return ErrorCode2;
|
|
14
15
|
})(ErrorCode || {});
|
|
@@ -59,23 +60,469 @@ var UtiliaSDKError = class _UtiliaSDKError extends Error {
|
|
|
59
60
|
}
|
|
60
61
|
};
|
|
61
62
|
|
|
63
|
+
// src/auth/oauth.service.ts
|
|
64
|
+
import axios from "axios";
|
|
65
|
+
|
|
66
|
+
// src/auth/pkce.ts
|
|
67
|
+
function base64UrlEncode(buffer) {
|
|
68
|
+
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
|
69
|
+
let binary = "";
|
|
70
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
71
|
+
binary += String.fromCharCode(bytes[i]);
|
|
72
|
+
}
|
|
73
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
74
|
+
}
|
|
75
|
+
function generateCodeVerifier(length = 64) {
|
|
76
|
+
const validLength = Math.max(43, Math.min(128, length));
|
|
77
|
+
const bytes = new Uint8Array(validLength);
|
|
78
|
+
crypto.getRandomValues(bytes);
|
|
79
|
+
return base64UrlEncode(bytes).slice(0, validLength);
|
|
80
|
+
}
|
|
81
|
+
async function generateCodeChallenge(verifier) {
|
|
82
|
+
const encoder = new TextEncoder();
|
|
83
|
+
const data = encoder.encode(verifier);
|
|
84
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
85
|
+
return base64UrlEncode(digest);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/auth/token-storage.ts
|
|
89
|
+
var MemoryTokenStorage = class {
|
|
90
|
+
constructor() {
|
|
91
|
+
this.tokens = null;
|
|
92
|
+
this.pendingState = null;
|
|
93
|
+
}
|
|
94
|
+
async getTokens() {
|
|
95
|
+
return this.tokens;
|
|
96
|
+
}
|
|
97
|
+
async setTokens(tokens) {
|
|
98
|
+
this.tokens = tokens;
|
|
99
|
+
}
|
|
100
|
+
async clearTokens() {
|
|
101
|
+
this.tokens = null;
|
|
102
|
+
}
|
|
103
|
+
async getPendingState() {
|
|
104
|
+
return this.pendingState;
|
|
105
|
+
}
|
|
106
|
+
async setPendingState(state) {
|
|
107
|
+
this.pendingState = state;
|
|
108
|
+
}
|
|
109
|
+
async clearPendingState() {
|
|
110
|
+
this.pendingState = null;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
var FileTokenStorage = class {
|
|
114
|
+
/**
|
|
115
|
+
* @param filePath - Ruta absoluta al archivo donde se guardarán los tokens
|
|
116
|
+
*/
|
|
117
|
+
constructor(filePath) {
|
|
118
|
+
this.filePath = filePath;
|
|
119
|
+
}
|
|
120
|
+
async getTokens() {
|
|
121
|
+
try {
|
|
122
|
+
const fs = await import("fs/promises");
|
|
123
|
+
const content = await fs.readFile(this.filePath, "utf-8");
|
|
124
|
+
const data = JSON.parse(content);
|
|
125
|
+
if (data._pendingState) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return data;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async setTokens(tokens) {
|
|
134
|
+
const fs = await import("fs/promises");
|
|
135
|
+
await fs.writeFile(this.filePath, JSON.stringify(tokens, null, 2), { encoding: "utf-8", mode: 384 });
|
|
136
|
+
}
|
|
137
|
+
async clearTokens() {
|
|
138
|
+
try {
|
|
139
|
+
const fs = await import("fs/promises");
|
|
140
|
+
await fs.unlink(this.filePath);
|
|
141
|
+
} catch {
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
get pendingStatePath() {
|
|
145
|
+
return this.filePath.replace(/\.json$/, "") + ".pending.json";
|
|
146
|
+
}
|
|
147
|
+
async getPendingState() {
|
|
148
|
+
try {
|
|
149
|
+
const fs = await import("fs/promises");
|
|
150
|
+
const content = await fs.readFile(this.pendingStatePath, "utf-8");
|
|
151
|
+
return JSON.parse(content);
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async setPendingState(state) {
|
|
157
|
+
const fs = await import("fs/promises");
|
|
158
|
+
await fs.writeFile(this.pendingStatePath, JSON.stringify(state, null, 2), { encoding: "utf-8", mode: 384 });
|
|
159
|
+
}
|
|
160
|
+
async clearPendingState() {
|
|
161
|
+
try {
|
|
162
|
+
const fs = await import("fs/promises");
|
|
163
|
+
await fs.unlink(this.pendingStatePath);
|
|
164
|
+
} catch {
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// src/auth/oauth.service.ts
|
|
170
|
+
var OAuthService = class {
|
|
171
|
+
constructor(baseURL, config) {
|
|
172
|
+
/** Estado PKCE en memoria como fallback cuando el storage no soporta pendingState */
|
|
173
|
+
this._pendingState = null;
|
|
174
|
+
this.baseURL = baseURL.replace(/\/$/, "");
|
|
175
|
+
this.config = config;
|
|
176
|
+
this.storage = config.tokenStorage ?? new MemoryTokenStorage();
|
|
177
|
+
this.http = axios.create({
|
|
178
|
+
baseURL: this.baseURL,
|
|
179
|
+
timeout: 3e4,
|
|
180
|
+
headers: { "Content-Type": "application/json" }
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Genera la URL de autorización OAuth con PKCE y almacena el estado
|
|
185
|
+
* pendiente automáticamente. Usar con handleCallback(code) sin codeVerifier.
|
|
186
|
+
*
|
|
187
|
+
* Este es el método recomendado para la mayoría de los casos.
|
|
188
|
+
*
|
|
189
|
+
* @param options - Opciones adicionales (state personalizado, scopes)
|
|
190
|
+
* @returns URL de autorización lista para redirigir al usuario
|
|
191
|
+
*/
|
|
192
|
+
async getAuthorizationUrl(options) {
|
|
193
|
+
const result = await this.getLoginUrl(options);
|
|
194
|
+
const pending = {
|
|
195
|
+
codeVerifier: result.codeVerifier,
|
|
196
|
+
state: result.state
|
|
197
|
+
};
|
|
198
|
+
this._pendingState = pending;
|
|
199
|
+
if (this.storage.setPendingState) {
|
|
200
|
+
await this.storage.setPendingState(pending);
|
|
201
|
+
}
|
|
202
|
+
return result.url;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Genera la URL de inicio de sesión OAuth con PKCE.
|
|
206
|
+
*
|
|
207
|
+
* Método de control manual: devuelve codeVerifier y state que el desarrollador
|
|
208
|
+
* debe gestionar. Para un flujo automático, usar getAuthorizationUrl() en su lugar.
|
|
209
|
+
*
|
|
210
|
+
* @param options - Opciones adicionales (state personalizado, scopes)
|
|
211
|
+
* @returns URL de autorización, code_verifier y state
|
|
212
|
+
*/
|
|
213
|
+
async getLoginUrl(options) {
|
|
214
|
+
const codeVerifier = generateCodeVerifier();
|
|
215
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
216
|
+
const state = options?.state ?? Array.from(crypto.getRandomValues(new Uint8Array(24)), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
217
|
+
const scopes = options?.scopes ?? this.config.scopes ?? ["openid", "profile", "email"];
|
|
218
|
+
const params = new URLSearchParams({
|
|
219
|
+
response_type: "code",
|
|
220
|
+
client_id: this.config.clientId,
|
|
221
|
+
redirect_uri: this.config.redirectUri,
|
|
222
|
+
scope: scopes.join(" "),
|
|
223
|
+
state,
|
|
224
|
+
code_challenge: codeChallenge,
|
|
225
|
+
code_challenge_method: "S256"
|
|
226
|
+
});
|
|
227
|
+
return {
|
|
228
|
+
url: `${this.baseURL}/oauth/authorize?${params.toString()}`,
|
|
229
|
+
codeVerifier,
|
|
230
|
+
state
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Abre una ventana emergente para inicio de sesión OAuth y devuelve tokens al completarse.
|
|
235
|
+
*
|
|
236
|
+
* @param options - Opciones de configuración de la ventana emergente
|
|
237
|
+
* @returns Tokens OAuth
|
|
238
|
+
* @throws Error si la ventana es bloqueada, cerrada o excede el tiempo límite
|
|
239
|
+
*/
|
|
240
|
+
async loginWithPopup(options) {
|
|
241
|
+
if (typeof window === "undefined") {
|
|
242
|
+
throw new Error("loginWithPopup solo est\xE1 disponible en entornos de navegador.");
|
|
243
|
+
}
|
|
244
|
+
const { url, codeVerifier, state } = await this.getLoginUrl({
|
|
245
|
+
state: options?.state,
|
|
246
|
+
scopes: options?.scopes
|
|
247
|
+
});
|
|
248
|
+
const width = options?.width ?? 500;
|
|
249
|
+
const height = options?.height ?? 700;
|
|
250
|
+
const timeout = options?.timeout ?? 3e5;
|
|
251
|
+
const left = Math.max(0, Math.round(window.screenX + (window.outerWidth - width) / 2));
|
|
252
|
+
const top = Math.max(0, Math.round(window.screenY + (window.outerHeight - height) / 2));
|
|
253
|
+
const features = `width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes,status=yes`;
|
|
254
|
+
const popup = window.open(url, "utilia-oauth-popup", features);
|
|
255
|
+
if (!popup || popup.closed) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
"No se pudo abrir la ventana de autorizaci\xF3n. Verifica que los popups no est\xE9n bloqueados."
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return new Promise((resolve, reject) => {
|
|
261
|
+
let timeoutId;
|
|
262
|
+
let pollId;
|
|
263
|
+
const cleanup = () => {
|
|
264
|
+
clearTimeout(timeoutId);
|
|
265
|
+
clearInterval(pollId);
|
|
266
|
+
window.removeEventListener("message", onMessage);
|
|
267
|
+
};
|
|
268
|
+
const onMessage = async (event) => {
|
|
269
|
+
if (event.origin !== window.location.origin) return;
|
|
270
|
+
const data = event.data;
|
|
271
|
+
if (!data || typeof data !== "object") return;
|
|
272
|
+
if (data.type === "oauth-mcp-success" && data.state === state) {
|
|
273
|
+
cleanup();
|
|
274
|
+
try {
|
|
275
|
+
if (popup && !popup.closed) popup.close();
|
|
276
|
+
} catch {
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const tokens = await this.handleCallback(data.code, codeVerifier);
|
|
280
|
+
resolve(tokens);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
reject(err);
|
|
283
|
+
}
|
|
284
|
+
} else if (data.type === "oauth-mcp-error" && data.state === state) {
|
|
285
|
+
cleanup();
|
|
286
|
+
try {
|
|
287
|
+
if (popup && !popup.closed) popup.close();
|
|
288
|
+
} catch {
|
|
289
|
+
}
|
|
290
|
+
reject(new Error(data.error_description || data.error || "Autorizaci\xF3n denegada"));
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
window.addEventListener("message", onMessage);
|
|
294
|
+
pollId = setInterval(() => {
|
|
295
|
+
if (popup.closed) {
|
|
296
|
+
cleanup();
|
|
297
|
+
reject(new Error("La ventana de autorizaci\xF3n fue cerrada antes de completar el proceso."));
|
|
298
|
+
}
|
|
299
|
+
}, 500);
|
|
300
|
+
timeoutId = setTimeout(() => {
|
|
301
|
+
cleanup();
|
|
302
|
+
try {
|
|
303
|
+
if (popup && !popup.closed) popup.close();
|
|
304
|
+
} catch {
|
|
305
|
+
}
|
|
306
|
+
reject(new Error("El proceso de autorizaci\xF3n excedi\xF3 el tiempo l\xEDmite."));
|
|
307
|
+
}, timeout);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Intercambia el código de autorización por tokens.
|
|
312
|
+
*
|
|
313
|
+
* Si se llama sin codeVerifier, recupera automáticamente el estado PKCE
|
|
314
|
+
* almacenado por getAuthorizationUrl() y valida el state CSRF.
|
|
315
|
+
*
|
|
316
|
+
* @param code - Código de autorización recibido en el callback
|
|
317
|
+
* @param codeVerifier - Verificador PKCE (opcional si se usó getAuthorizationUrl)
|
|
318
|
+
* @param callbackState - Valor de state recibido en el callback (para validación CSRF automática)
|
|
319
|
+
* @returns Tokens OAuth
|
|
320
|
+
*/
|
|
321
|
+
async handleCallback(code, codeVerifier, callbackState) {
|
|
322
|
+
let verifier = codeVerifier;
|
|
323
|
+
if (!verifier) {
|
|
324
|
+
const pending = await this.getPendingState();
|
|
325
|
+
if (!pending) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
"No se encontr\xF3 el estado PKCE pendiente. Usa getAuthorizationUrl() antes de handleCallback(), o proporciona el codeVerifier manualmente."
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
verifier = pending.codeVerifier;
|
|
331
|
+
if (callbackState && callbackState !== pending.state) {
|
|
332
|
+
await this.clearPendingState();
|
|
333
|
+
throw new Error(
|
|
334
|
+
`El valor de state no coincide. Posible ataque CSRF. Esperado: ${pending.state}, recibido: ${callbackState}`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
await this.clearPendingState();
|
|
338
|
+
}
|
|
339
|
+
const body = {
|
|
340
|
+
grant_type: "authorization_code",
|
|
341
|
+
code,
|
|
342
|
+
redirect_uri: this.config.redirectUri,
|
|
343
|
+
code_verifier: verifier,
|
|
344
|
+
client_id: this.config.clientId
|
|
345
|
+
};
|
|
346
|
+
if (this.config.clientSecret) {
|
|
347
|
+
body.client_secret = this.config.clientSecret;
|
|
348
|
+
}
|
|
349
|
+
const response = await this.http.post("/oauth/token", body);
|
|
350
|
+
const data = response.data;
|
|
351
|
+
const tokens = {
|
|
352
|
+
accessToken: data.access_token,
|
|
353
|
+
refreshToken: data.refresh_token,
|
|
354
|
+
expiresIn: data.expires_in,
|
|
355
|
+
expiresAt: Date.now() + data.expires_in * 1e3,
|
|
356
|
+
tokenType: data.token_type || "Bearer",
|
|
357
|
+
scope: data.scope,
|
|
358
|
+
idToken: data.id_token
|
|
359
|
+
};
|
|
360
|
+
await this.storage.setTokens(tokens);
|
|
361
|
+
return tokens;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Refresca el token de acceso usando el refresh_token
|
|
365
|
+
*
|
|
366
|
+
* @returns Nuevos tokens OAuth
|
|
367
|
+
* @throws Error si no hay refresh_token disponible
|
|
368
|
+
*/
|
|
369
|
+
async refreshToken() {
|
|
370
|
+
const current = await this.storage.getTokens();
|
|
371
|
+
if (!current?.refreshToken) {
|
|
372
|
+
throw new Error("No hay refresh token disponible. El usuario debe volver a autenticarse.");
|
|
373
|
+
}
|
|
374
|
+
const body = {
|
|
375
|
+
grant_type: "refresh_token",
|
|
376
|
+
refresh_token: current.refreshToken,
|
|
377
|
+
client_id: this.config.clientId
|
|
378
|
+
};
|
|
379
|
+
if (this.config.clientSecret) {
|
|
380
|
+
body.client_secret = this.config.clientSecret;
|
|
381
|
+
}
|
|
382
|
+
const response = await this.http.post("/oauth/token", body);
|
|
383
|
+
const data = response.data;
|
|
384
|
+
const tokens = {
|
|
385
|
+
accessToken: data.access_token,
|
|
386
|
+
refreshToken: data.refresh_token ?? current.refreshToken,
|
|
387
|
+
expiresIn: data.expires_in,
|
|
388
|
+
expiresAt: Date.now() + data.expires_in * 1e3,
|
|
389
|
+
tokenType: data.token_type || "Bearer",
|
|
390
|
+
scope: data.scope,
|
|
391
|
+
idToken: data.id_token
|
|
392
|
+
};
|
|
393
|
+
await this.storage.setTokens(tokens);
|
|
394
|
+
return tokens;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Obtiene información del usuario autenticado
|
|
398
|
+
*
|
|
399
|
+
* @returns Datos del usuario desde /oauth/userinfo
|
|
400
|
+
*/
|
|
401
|
+
async getUserInfo() {
|
|
402
|
+
const accessToken = await this.getAccessToken();
|
|
403
|
+
const response = await this.http.get("/oauth/userinfo", {
|
|
404
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
405
|
+
});
|
|
406
|
+
return response.data;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Revoca el token actual
|
|
410
|
+
*
|
|
411
|
+
* @param tokenType - Tipo de token a revocar ('access_token' | 'refresh_token').
|
|
412
|
+
* Por defecto revoca el access_token.
|
|
413
|
+
*/
|
|
414
|
+
async revokeToken(tokenType) {
|
|
415
|
+
const current = await this.storage.getTokens();
|
|
416
|
+
if (!current) return;
|
|
417
|
+
const token = tokenType === "refresh_token" ? current.refreshToken : current.accessToken;
|
|
418
|
+
if (!token) return;
|
|
419
|
+
const body = {
|
|
420
|
+
token,
|
|
421
|
+
client_id: this.config.clientId
|
|
422
|
+
};
|
|
423
|
+
if (tokenType) {
|
|
424
|
+
body.token_type_hint = tokenType;
|
|
425
|
+
}
|
|
426
|
+
if (this.config.clientSecret) {
|
|
427
|
+
body.client_secret = this.config.clientSecret;
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
await this.http.post("/oauth/revoke", body);
|
|
431
|
+
} catch {
|
|
432
|
+
}
|
|
433
|
+
await this.storage.clearTokens();
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Obtiene un access token válido, refrescándolo automáticamente si ha expirado
|
|
437
|
+
*
|
|
438
|
+
* @returns Access token válido
|
|
439
|
+
* @throws Error si no hay tokens disponibles
|
|
440
|
+
*/
|
|
441
|
+
async getAccessToken() {
|
|
442
|
+
const tokens = await this.storage.getTokens();
|
|
443
|
+
if (!tokens) {
|
|
444
|
+
throw new Error("No hay tokens OAuth. El usuario debe iniciar sesi\xF3n primero.");
|
|
445
|
+
}
|
|
446
|
+
if (tokens.expiresAt < Date.now() + 3e4) {
|
|
447
|
+
const refreshed = await this.refreshToken();
|
|
448
|
+
return refreshed.accessToken;
|
|
449
|
+
}
|
|
450
|
+
return tokens.accessToken;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Verifica si hay una sesión OAuth activa con tokens válidos.
|
|
454
|
+
*
|
|
455
|
+
* @returns true si hay tokens almacenados y el access token no ha expirado
|
|
456
|
+
* (o si hay un refresh token disponible para renovarlo)
|
|
457
|
+
*/
|
|
458
|
+
async isAuthenticated() {
|
|
459
|
+
const tokens = await this.storage.getTokens();
|
|
460
|
+
if (!tokens) return false;
|
|
461
|
+
if (tokens.expiresAt > Date.now() + 3e4) return true;
|
|
462
|
+
return !!tokens.refreshToken;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Cierra la sesión OAuth: revoca los tokens y limpia el almacenamiento.
|
|
466
|
+
*/
|
|
467
|
+
async logout() {
|
|
468
|
+
await this.revokeToken();
|
|
469
|
+
}
|
|
470
|
+
// -- Helpers internos para estado PKCE pendiente --
|
|
471
|
+
async getPendingState() {
|
|
472
|
+
if (this.storage.getPendingState) {
|
|
473
|
+
const stored = await this.storage.getPendingState();
|
|
474
|
+
if (stored) return stored;
|
|
475
|
+
}
|
|
476
|
+
return this._pendingState;
|
|
477
|
+
}
|
|
478
|
+
async clearPendingState() {
|
|
479
|
+
this._pendingState = null;
|
|
480
|
+
if (this.storage.clearPendingState) {
|
|
481
|
+
await this.storage.clearPendingState();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
|
|
62
486
|
// src/client.ts
|
|
63
487
|
var UtiliaClient = class {
|
|
64
488
|
constructor(config) {
|
|
489
|
+
/** Promesa compartida para evitar refreshes concurrentes */
|
|
490
|
+
this._refreshPromise = null;
|
|
491
|
+
if (!config.apiKey && !config.oauth) {
|
|
492
|
+
throw new UtiliaSDKError(
|
|
493
|
+
"VALIDATION_ERROR" /* VALIDATION_ERROR */,
|
|
494
|
+
"Debes proporcionar apiKey o configuraci\xF3n oauth"
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
if (config.apiKey && config.oauth) {
|
|
498
|
+
throw new UtiliaSDKError(
|
|
499
|
+
"VALIDATION_ERROR" /* VALIDATION_ERROR */,
|
|
500
|
+
"No puedes proporcionar apiKey y oauth a la vez. Usa uno u otro."
|
|
501
|
+
);
|
|
502
|
+
}
|
|
65
503
|
this.config = {
|
|
66
504
|
timeout: 3e4,
|
|
67
505
|
retryAttempts: 3,
|
|
68
506
|
debug: false,
|
|
69
507
|
...config
|
|
70
508
|
};
|
|
71
|
-
|
|
509
|
+
const headers = {
|
|
510
|
+
"Content-Type": "application/json"
|
|
511
|
+
};
|
|
512
|
+
if (this.config.apiKey) {
|
|
513
|
+
headers["X-Api-Key"] = this.config.apiKey;
|
|
514
|
+
}
|
|
515
|
+
this.axios = axios2.create({
|
|
72
516
|
baseURL: this.config.baseURL,
|
|
73
517
|
timeout: this.config.timeout,
|
|
74
|
-
headers
|
|
75
|
-
"Content-Type": "application/json",
|
|
76
|
-
"X-Api-Key": this.config.apiKey
|
|
77
|
-
}
|
|
518
|
+
headers
|
|
78
519
|
});
|
|
520
|
+
if (this.config.oauth) {
|
|
521
|
+
this._oauthService = new OAuthService(this.config.baseURL, this.config.oauth);
|
|
522
|
+
if (this.config.oauth.tokenStorage) {
|
|
523
|
+
}
|
|
524
|
+
this.setupOAuthInterceptors();
|
|
525
|
+
}
|
|
79
526
|
this.axios.interceptors.request.use((config2) => {
|
|
80
527
|
const requestId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : this.generateSimpleId();
|
|
81
528
|
config2.headers["x-request-id"] = requestId;
|
|
@@ -84,20 +531,71 @@ var UtiliaClient = class {
|
|
|
84
531
|
});
|
|
85
532
|
this.setupInterceptors();
|
|
86
533
|
}
|
|
534
|
+
/**
|
|
535
|
+
* Acceso al servicio OAuth (solo disponible en modo OAuth)
|
|
536
|
+
*/
|
|
537
|
+
get oauth() {
|
|
538
|
+
if (!this._oauthService) {
|
|
539
|
+
throw new UtiliaSDKError(
|
|
540
|
+
"VALIDATION_ERROR" /* VALIDATION_ERROR */,
|
|
541
|
+
"El servicio OAuth no est\xE1 disponible. Configura oauth en el constructor del SDK."
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
return this._oauthService;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Configura interceptores para autenticación OAuth
|
|
548
|
+
*/
|
|
549
|
+
setupOAuthInterceptors() {
|
|
550
|
+
this.axios.interceptors.request.use(async (config) => {
|
|
551
|
+
if (this._oauthService) {
|
|
552
|
+
try {
|
|
553
|
+
const accessToken = await this._oauthService.getAccessToken();
|
|
554
|
+
config.headers["Authorization"] = `Bearer ${accessToken}`;
|
|
555
|
+
} catch {
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return config;
|
|
559
|
+
});
|
|
560
|
+
this.axios.interceptors.response.use(
|
|
561
|
+
(response) => response,
|
|
562
|
+
async (error) => {
|
|
563
|
+
const originalRequest = error.config;
|
|
564
|
+
if (error.response?.status === 401 && this._oauthService && originalRequest && !originalRequest._retried) {
|
|
565
|
+
originalRequest._retried = true;
|
|
566
|
+
if (!this._refreshPromise) {
|
|
567
|
+
this._refreshPromise = this._oauthService.refreshToken().then(() => {
|
|
568
|
+
}).finally(() => {
|
|
569
|
+
this._refreshPromise = null;
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
try {
|
|
573
|
+
await this._refreshPromise;
|
|
574
|
+
const newToken = await this._oauthService.getAccessToken();
|
|
575
|
+
originalRequest.headers["Authorization"] = `Bearer ${newToken}`;
|
|
576
|
+
return this.axios(originalRequest);
|
|
577
|
+
} catch {
|
|
578
|
+
throw error;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
);
|
|
584
|
+
}
|
|
87
585
|
/**
|
|
88
586
|
* Configura los interceptores de request y response
|
|
89
587
|
*/
|
|
90
588
|
setupInterceptors() {
|
|
91
589
|
if (this.config.debug) {
|
|
92
590
|
this.axios.interceptors.request.use((request) => {
|
|
93
|
-
console.log("[UtiliaSDK]
|
|
591
|
+
console.log("[UtiliaSDK] Petici\xF3n:", request.method?.toUpperCase(), request.url);
|
|
94
592
|
return request;
|
|
95
593
|
});
|
|
96
594
|
}
|
|
97
595
|
if (this.config.debug) {
|
|
98
596
|
this.axios.interceptors.response.use(
|
|
99
597
|
(response) => {
|
|
100
|
-
console.log("[UtiliaSDK]
|
|
598
|
+
console.log("[UtiliaSDK] Respuesta:", response.status, response.config.url);
|
|
101
599
|
return response;
|
|
102
600
|
}
|
|
103
601
|
);
|
|
@@ -114,15 +612,15 @@ var UtiliaClient = class {
|
|
|
114
612
|
const errorOptions = { requestId, statusCode, errorCode };
|
|
115
613
|
if (!error.response) {
|
|
116
614
|
if (error.code === "ECONNABORTED") {
|
|
117
|
-
return new UtiliaSDKError("NETWORK_ERROR" /* NETWORK_ERROR */, "Timeout: la solicitud
|
|
615
|
+
return new UtiliaSDKError("NETWORK_ERROR" /* NETWORK_ERROR */, "Timeout: la solicitud excedi\xF3 el tiempo l\xEDmite", errorOptions);
|
|
118
616
|
}
|
|
119
|
-
return new UtiliaSDKError("NETWORK_ERROR" /* NETWORK_ERROR */, "Error de
|
|
617
|
+
return new UtiliaSDKError("NETWORK_ERROR" /* NETWORK_ERROR */, "Error de conexi\xF3n: no se pudo conectar con el servidor", errorOptions);
|
|
120
618
|
}
|
|
121
619
|
const status = error.response.status;
|
|
122
620
|
const message = data?.message || data?.error || error.message;
|
|
123
621
|
switch (status) {
|
|
124
622
|
case 401:
|
|
125
|
-
return new UtiliaSDKError("UNAUTHORIZED" /* UNAUTHORIZED */, message || "API Key
|
|
623
|
+
return new UtiliaSDKError("UNAUTHORIZED" /* UNAUTHORIZED */, message || "API Key inv\xE1lida o faltante", errorOptions);
|
|
126
624
|
case 403:
|
|
127
625
|
if (message?.toLowerCase().includes("rate limit")) {
|
|
128
626
|
return new UtiliaSDKError("RATE_LIMITED" /* RATE_LIMITED */, "Rate limit excedido. Intenta de nuevo mas tarde.", errorOptions);
|
|
@@ -135,7 +633,7 @@ var UtiliaClient = class {
|
|
|
135
633
|
return new UtiliaSDKError("NOT_FOUND" /* NOT_FOUND */, message || "Recurso no encontrado", errorOptions);
|
|
136
634
|
case 400:
|
|
137
635
|
case 422:
|
|
138
|
-
return new UtiliaSDKError("VALIDATION_ERROR" /* VALIDATION_ERROR */, message || "Error de
|
|
636
|
+
return new UtiliaSDKError("VALIDATION_ERROR" /* VALIDATION_ERROR */, message || "Error de validaci\xF3n en los datos enviados", errorOptions);
|
|
139
637
|
case 429:
|
|
140
638
|
return new UtiliaSDKError("RATE_LIMITED" /* RATE_LIMITED */, message || "Rate limit excedido. Intenta de nuevo mas tarde.", errorOptions);
|
|
141
639
|
case 500:
|
|
@@ -148,7 +646,7 @@ var UtiliaClient = class {
|
|
|
148
646
|
}
|
|
149
647
|
}
|
|
150
648
|
/**
|
|
151
|
-
* Determina si un error es recuperable y se debe reintentar
|
|
649
|
+
* Determina si un error es recuperable y se debe reintentar.
|
|
152
650
|
*/
|
|
153
651
|
isRetryableError(error) {
|
|
154
652
|
if (error instanceof AxiosError) {
|
|
@@ -162,7 +660,7 @@ var UtiliaClient = class {
|
|
|
162
660
|
return false;
|
|
163
661
|
}
|
|
164
662
|
/**
|
|
165
|
-
* Ejecuta una
|
|
663
|
+
* Ejecuta una función con reintentos y backoff exponencial
|
|
166
664
|
*/
|
|
167
665
|
async executeWithRetry(fn) {
|
|
168
666
|
let lastError;
|
|
@@ -180,7 +678,7 @@ var UtiliaClient = class {
|
|
|
180
678
|
if (attempt < this.config.retryAttempts - 1) {
|
|
181
679
|
const delay = 500 * Math.pow(2, attempt);
|
|
182
680
|
if (this.config.debug) {
|
|
183
|
-
console.log(`[UtiliaSDK]
|
|
681
|
+
console.log(`[UtiliaSDK] Reintento ${attempt + 1}/${this.config.retryAttempts - 1}, esperando ${delay}ms`);
|
|
184
682
|
}
|
|
185
683
|
await this.sleep(delay);
|
|
186
684
|
}
|
|
@@ -192,7 +690,7 @@ var UtiliaClient = class {
|
|
|
192
690
|
throw lastError;
|
|
193
691
|
}
|
|
194
692
|
/**
|
|
195
|
-
* Genera un ID simple como fallback cuando crypto.randomUUID no
|
|
693
|
+
* Genera un ID simple como fallback cuando crypto.randomUUID no está disponible
|
|
196
694
|
*/
|
|
197
695
|
generateSimpleId() {
|
|
198
696
|
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
@@ -204,19 +702,44 @@ var UtiliaClient = class {
|
|
|
204
702
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
205
703
|
}
|
|
206
704
|
/**
|
|
207
|
-
* Construye una URL completa para conexiones SSE con
|
|
208
|
-
*
|
|
705
|
+
* Construye una URL completa para conexiones SSE con credenciales como query param.
|
|
706
|
+
* Necesario porque EventSource no soporta headers personalizados.
|
|
209
707
|
*
|
|
210
708
|
* @param path - Ruta relativa de la API (ej: /external/v1/tickets/ai-suggest/123/stream)
|
|
211
|
-
* @returns URL completa con
|
|
709
|
+
* @returns URL completa con credenciales como parametro de consulta
|
|
212
710
|
*/
|
|
213
711
|
buildSseUrl(path) {
|
|
214
712
|
const base = this.config.baseURL.replace(/\/$/, "");
|
|
215
713
|
const separator = path.includes("?") ? "&" : "?";
|
|
216
|
-
|
|
714
|
+
if (this.config.apiKey) {
|
|
715
|
+
return `${base}${path}${separator}apiKey=${this.config.apiKey}`;
|
|
716
|
+
}
|
|
717
|
+
return `${base}${path}`;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Construye una URL completa para conexiones SSE, incluyendo token OAuth si está disponible.
|
|
721
|
+
* Versión asíncrona que obtiene el access token actual.
|
|
722
|
+
*
|
|
723
|
+
* @param path - Ruta relativa de la API
|
|
724
|
+
* @returns URL completa con credenciales como parametro de consulta
|
|
725
|
+
*/
|
|
726
|
+
async buildSseUrlAsync(path) {
|
|
727
|
+
const base = this.config.baseURL.replace(/\/$/, "");
|
|
728
|
+
const separator = path.includes("?") ? "&" : "?";
|
|
729
|
+
if (this.config.apiKey) {
|
|
730
|
+
return `${base}${path}${separator}apiKey=${this.config.apiKey}`;
|
|
731
|
+
}
|
|
732
|
+
if (this._oauthService) {
|
|
733
|
+
try {
|
|
734
|
+
const accessToken = await this._oauthService.getAccessToken();
|
|
735
|
+
return `${base}${path}${separator}access_token=${accessToken}`;
|
|
736
|
+
} catch {
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return `${base}${path}`;
|
|
217
740
|
}
|
|
218
741
|
/**
|
|
219
|
-
* Realiza una
|
|
742
|
+
* Realiza una petición GET
|
|
220
743
|
*/
|
|
221
744
|
async get(url, config) {
|
|
222
745
|
return this.executeWithRetry(async () => {
|
|
@@ -596,6 +1119,54 @@ var TicketsService = class {
|
|
|
596
1119
|
{ params: { userId } }
|
|
597
1120
|
);
|
|
598
1121
|
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Suscribirse a actualizaciones de tickets en tiempo real via SSE.
|
|
1124
|
+
* Recibe eventos cuando un agente responde, cambia el estado, resuelve o cierra un ticket.
|
|
1125
|
+
*
|
|
1126
|
+
* Requiere un entorno con EventSource nativo (navegador) o un polyfill como 'eventsource'.
|
|
1127
|
+
*
|
|
1128
|
+
* @param userId - ID externo del usuario (opcional, filtra eventos por usuario)
|
|
1129
|
+
* @param options - Callbacks para eventos y errores
|
|
1130
|
+
* @returns Objeto con metodo close() para cerrar la conexion
|
|
1131
|
+
*
|
|
1132
|
+
* @example
|
|
1133
|
+
* ```typescript
|
|
1134
|
+
* const stream = sdk.tickets.streamUpdates('user-123', {
|
|
1135
|
+
* onTicketUpdated: (event) => {
|
|
1136
|
+
* console.log(`Ticket ${event.ticketKey} actualizado: ${event.type}`);
|
|
1137
|
+
* // Refrescar la UI del ticket
|
|
1138
|
+
* },
|
|
1139
|
+
* });
|
|
1140
|
+
*
|
|
1141
|
+
* // Para cerrar la conexion:
|
|
1142
|
+
* stream.close();
|
|
1143
|
+
* ```
|
|
1144
|
+
*/
|
|
1145
|
+
streamUpdates(userId, options) {
|
|
1146
|
+
if (typeof EventSource === "undefined") {
|
|
1147
|
+
throw new Error(
|
|
1148
|
+
'EventSource no esta disponible en este entorno. Para Node.js, instala un polyfill como "eventsource" y asignalo a globalThis.EventSource.'
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
const queryParams = userId ? `?userId=${encodeURIComponent(userId)}` : "";
|
|
1152
|
+
const sseUrl = this.client.buildSseUrl(`${this.basePath}/events${queryParams}`);
|
|
1153
|
+
const eventSource = new EventSource(sseUrl);
|
|
1154
|
+
eventSource.onmessage = (event) => {
|
|
1155
|
+
try {
|
|
1156
|
+
const data = JSON.parse(event.data);
|
|
1157
|
+
if (data.event === "ticket-updated" && options?.onTicketUpdated) {
|
|
1158
|
+
options.onTicketUpdated(data);
|
|
1159
|
+
}
|
|
1160
|
+
} catch {
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
eventSource.onerror = () => {
|
|
1164
|
+
options?.onError?.(new Error("Error en la conexion SSE"));
|
|
1165
|
+
};
|
|
1166
|
+
return {
|
|
1167
|
+
close: () => eventSource.close()
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
599
1170
|
/**
|
|
600
1171
|
* Cerrar un ticket
|
|
601
1172
|
* Solo el usuario que creo el ticket puede cerrarlo
|
|
@@ -1101,39 +1672,57 @@ var UtiliaSDK = class {
|
|
|
1101
1672
|
/**
|
|
1102
1673
|
* Crea una nueva instancia del SDK
|
|
1103
1674
|
*
|
|
1104
|
-
* @param config -
|
|
1675
|
+
* @param config - Configuración del SDK
|
|
1105
1676
|
*
|
|
1106
|
-
* @example
|
|
1677
|
+
* @example Autenticación con API Key
|
|
1107
1678
|
* ```typescript
|
|
1108
1679
|
* const sdk = new UtiliaSDK({
|
|
1109
1680
|
* baseURL: 'https://os.utilia.ai/api',
|
|
1110
1681
|
* apiKey: 'tu-api-key',
|
|
1111
|
-
* timeout: 30000, // opcional
|
|
1112
|
-
* debug: true, // opcional, habilita logs
|
|
1113
1682
|
* });
|
|
1683
|
+
* ```
|
|
1114
1684
|
*
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1117
|
-
* const
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1685
|
+
* @example Autenticación con OAuth 2.1 + PKCE
|
|
1686
|
+
* ```typescript
|
|
1687
|
+
* const sdk = new UtiliaSDK({
|
|
1688
|
+
* baseURL: 'https://os.utilia.ai/api',
|
|
1689
|
+
* oauth: {
|
|
1690
|
+
* clientId: 'tu-client-id',
|
|
1691
|
+
* redirectUri: 'https://tu-app.com/callback',
|
|
1692
|
+
* },
|
|
1122
1693
|
* });
|
|
1694
|
+
*
|
|
1695
|
+
* // Obtener URL de autorización (gestiona PKCE automáticamente)
|
|
1696
|
+
* const authUrl = await sdk.oauth.getAuthorizationUrl();
|
|
1697
|
+
*
|
|
1698
|
+
* // Tras el callback (recupera PKCE automáticamente)
|
|
1699
|
+
* const tokens = await sdk.oauth.handleCallback(code);
|
|
1123
1700
|
* ```
|
|
1124
1701
|
*/
|
|
1125
1702
|
constructor(config) {
|
|
1126
|
-
|
|
1127
|
-
this.errors = new ErrorsService(
|
|
1128
|
-
this.files = new FilesService(
|
|
1129
|
-
this.tickets = new TicketsService(
|
|
1130
|
-
this.users = new UsersService(
|
|
1131
|
-
this.ai = new AiService(
|
|
1703
|
+
this._client = new UtiliaClient(config);
|
|
1704
|
+
this.errors = new ErrorsService(this._client);
|
|
1705
|
+
this.files = new FilesService(this._client);
|
|
1706
|
+
this.tickets = new TicketsService(this._client);
|
|
1707
|
+
this.users = new UsersService(this._client);
|
|
1708
|
+
this.ai = new AiService(this._client);
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* Servicio OAuth (solo disponible si se configuro oauth en el constructor)
|
|
1712
|
+
*/
|
|
1713
|
+
get oauth() {
|
|
1714
|
+
return this._client.oauth;
|
|
1132
1715
|
}
|
|
1133
1716
|
};
|
|
1134
1717
|
export {
|
|
1135
1718
|
ErrorCode,
|
|
1719
|
+
FileTokenStorage,
|
|
1720
|
+
MemoryTokenStorage,
|
|
1721
|
+
OAuthService,
|
|
1136
1722
|
SDK_LIMITS,
|
|
1137
1723
|
UtiliaSDK,
|
|
1138
|
-
UtiliaSDKError
|
|
1724
|
+
UtiliaSDKError,
|
|
1725
|
+
base64UrlEncode,
|
|
1726
|
+
generateCodeChallenge,
|
|
1727
|
+
generateCodeVerifier
|
|
1139
1728
|
};
|