@pimia/sdk 0.20.0 → 0.27.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 +80 -0
- package/dist/api.d.ts +1020 -116
- package/dist/central-api.d.ts +1928 -0
- package/dist/central-api.js +9 -0
- package/dist/central.d.ts +607 -0
- package/dist/central.js +257 -0
- package/dist/client.d.ts +261 -1
- package/dist/client.js +212 -2
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +24 -0
- package/dist/index.d.ts +23 -3
- package/dist/index.js +21 -2
- package/dist/tokens.d.ts +45 -0
- package/dist/tokens.js +53 -0
- package/package.json +5 -1
package/dist/client.js
CHANGED
|
@@ -15,8 +15,15 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { NotAuthenticatedError, OAuthError, PimiaApiError, RateLimitError, UnauthorizedError, } from './errors.js';
|
|
17
17
|
import { OAuth } from './oauth.js';
|
|
18
|
-
import { isExpired } from './tokens.js';
|
|
18
|
+
import { BorrowedTokenStore, isExpired } from './tokens.js';
|
|
19
19
|
export class PimiaClient {
|
|
20
|
+
/**
|
|
21
|
+
* La ceremonia OAuth, o `null` si este cliente no tiene grant propio
|
|
22
|
+
* ({@link PimiaClient.withBorrowedToken}). Es `null` y no un objeto a medias
|
|
23
|
+
* a propósito: un `OAuth` sin `clientId` compondría una URL de autorización
|
|
24
|
+
* con `client_id=` vacío y el fallo aparecería en el navegador del usuario,
|
|
25
|
+
* lejos de aquí.
|
|
26
|
+
*/
|
|
20
27
|
oauth;
|
|
21
28
|
baseUrl;
|
|
22
29
|
doFetch;
|
|
@@ -29,7 +36,9 @@ export class PimiaClient {
|
|
|
29
36
|
refreshing = null;
|
|
30
37
|
lastRateLimit = {};
|
|
31
38
|
constructor(options) {
|
|
32
|
-
|
|
39
|
+
/* Sin `clientId` no hay a quién identificar ante el Authorization Server:
|
|
40
|
+
este cliente no tiene grant propio y no puede tener ceremonia. */
|
|
41
|
+
this.oauth = options.clientId ? new OAuth(options) : null;
|
|
33
42
|
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
|
34
43
|
this.doFetch = options.fetch ?? globalThis.fetch;
|
|
35
44
|
this.store = options.tokens;
|
|
@@ -38,6 +47,63 @@ export class PimiaClient {
|
|
|
38
47
|
this.maxRetryDelayMs = options.maxRetryDelayMs ?? 30_000;
|
|
39
48
|
this.extraHeaders = options.headers ?? {};
|
|
40
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Un cliente que **reenvía el token de otro**, sin identidad propia.
|
|
52
|
+
*
|
|
53
|
+
* ── Cuándo es esto lo correcto ─────────────────────────────────────────────
|
|
54
|
+
*
|
|
55
|
+
* Cuando tu servicio se sienta DELANTE de un usuario que ya entró en Pimia:
|
|
56
|
+
* el front te manda su `Authorization` y tú lo reenvías. No necesitas
|
|
57
|
+
* `clientId`, ni `clientSecret`, ni `redirectUri`, ni un `TokenStore` —no hay
|
|
58
|
+
* nada tuyo que guardar— y ganas la propiedad que hace esto seguro: **Pimia
|
|
59
|
+
* sigue decidiendo los permisos**. Tu servicio no puede darle a nadie más de
|
|
60
|
+
* lo que su token ya le daba, así que no hay una credencial de servicio que
|
|
61
|
+
* auditar aparte.
|
|
62
|
+
*
|
|
63
|
+
* ```ts
|
|
64
|
+
* const pimia = PimiaClient.withBorrowedToken({
|
|
65
|
+
* baseUrl: `https://${tenant}.pimia.es`,
|
|
66
|
+
* accessToken: bearerDeQuienLlama,
|
|
67
|
+
* // La empresa activa viaja en cabecera, como en todo el API. OMÍTELA
|
|
68
|
+
* // cuando no la sepas: `company:` vacía es una cabecera presente que no
|
|
69
|
+
* // casa con ninguna empresa.
|
|
70
|
+
* headers: empresa === null ? {} : { company: String(empresa) },
|
|
71
|
+
* // Atiendes una petición web: no esperes dentro de ella.
|
|
72
|
+
* maxRateLimitRetries: 0,
|
|
73
|
+
* })
|
|
74
|
+
*
|
|
75
|
+
* await pimia.bootstrap.currentCompanyId()
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* ⚠️ **El token vive lo que viva la petición que lo trajo.** Construye uno por
|
|
79
|
+
* petición y no compartas la instancia: un cliente compartido es una
|
|
80
|
+
* credencial compartida, y aquí la credencial es de un usuario concreto.
|
|
81
|
+
*
|
|
82
|
+
* ⚠️ Un token prestado **no se refresca**: cuando caduca, el 401 sube como
|
|
83
|
+
* {@link UnauthorizedError} y quien tiene que conseguir otro es quien te lo
|
|
84
|
+
* prestó.
|
|
85
|
+
*/
|
|
86
|
+
static withBorrowedToken(options) {
|
|
87
|
+
if (options.accessToken.trim() === '') {
|
|
88
|
+
/* Antes de construir nada: un cliente con el token vacío llamaría igual y
|
|
89
|
+
el 401 llegaría desde Pimia, que es tarde y confuso — parece un token
|
|
90
|
+
caducado y es un token que nunca hubo. */
|
|
91
|
+
throw new NotAuthenticatedError('El token prestado está vacío: no hay nada que reenviar. Comprueba la cabecera ' +
|
|
92
|
+
'`Authorization` de la petición que atiendes.');
|
|
93
|
+
}
|
|
94
|
+
return new PimiaClient({
|
|
95
|
+
baseUrl: options.baseUrl,
|
|
96
|
+
// Vacíos: es lo que dice «este cliente no tiene grant propio», y lo que
|
|
97
|
+
// deja `oauth` a null.
|
|
98
|
+
clientId: '',
|
|
99
|
+
redirectUri: '',
|
|
100
|
+
fetch: options.fetch,
|
|
101
|
+
tokens: new BorrowedTokenStore(options.accessToken),
|
|
102
|
+
headers: options.headers,
|
|
103
|
+
maxRateLimitRetries: options.maxRateLimitRetries,
|
|
104
|
+
maxRetryDelayMs: options.maxRetryDelayMs,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
41
107
|
/** Cabeceras `X-RateLimit-*` de la última respuesta. */
|
|
42
108
|
get rateLimit() {
|
|
43
109
|
return { ...this.lastRateLimit };
|
|
@@ -294,6 +360,142 @@ export class PimiaClient {
|
|
|
294
360
|
adjust: (itemId, body, options) => this.post(`/items/${itemId}/stock-adjustments`, body, options),
|
|
295
361
|
};
|
|
296
362
|
}
|
|
363
|
+
/**
|
|
364
|
+
* Oportunidades: **a quién va dirigido** un presupuesto.
|
|
365
|
+
*
|
|
366
|
+
* `estimates.opportunity_id` es el enlace transparente del núcleo —funciona
|
|
367
|
+
* venga el CRM de donde venga—, y es lo que permite preguntar «los
|
|
368
|
+
* presupuestos de este trato» sin que el trato viva en Pimia. Pero hasta el
|
|
369
|
+
* 2026-09-08 una oportunidad **sólo podía nacer dentro de un
|
|
370
|
+
* `POST /estimates`**, así que un CRM de fuera no tenía forma de estrenar una
|
|
371
|
+
* al dar de alta un lead: habría tenido que fabricar un presupuesto borrador y
|
|
372
|
+
* quemar un número de la serie del cliente por cada lead. Un lead no es una
|
|
373
|
+
* oferta.
|
|
374
|
+
*
|
|
375
|
+
* No estrena scope: cuelga de `estimates:write`, porque la oportunidad es a
|
|
376
|
+
* quién va dirigido un presupuesto y no una entidad del embudo.
|
|
377
|
+
*
|
|
378
|
+
* ⚠️ **Todavía no está en el spec publicado** (galeote/factSaas#805 es más
|
|
379
|
+
* nueva que la última sincronización del contrato). Contra una instancia
|
|
380
|
+
* anterior a esa ruta la llamada contesta 404, y eso es lo que hay que mirar
|
|
381
|
+
* antes de dar por hecho que el token está mal.
|
|
382
|
+
*/
|
|
383
|
+
get opportunities() {
|
|
384
|
+
return {
|
|
385
|
+
/**
|
|
386
|
+
* Estrena una oportunidad. Manda `idempotencyKey` —una clave estable por
|
|
387
|
+
* lead, del estilo `lead:{id}:opportunity`— y el reintento tras un timeout
|
|
388
|
+
* no te estrenará una segunda para el mismo trato.
|
|
389
|
+
*/
|
|
390
|
+
create: (body, options) => this.post('/opportunities', body, options),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Lo que el CRM de Pimia publica para que OTRO CRM pueda sustituirlo.
|
|
395
|
+
*
|
|
396
|
+
* No son los leads —ésos los sirve `/crm/leads` y un integrador que trae su
|
|
397
|
+
* propio embudo no los usa—: es lo que un CRM sustituto necesita del núcleo
|
|
398
|
+
* aunque se haya llevado el embudo a su casa.
|
|
399
|
+
*/
|
|
400
|
+
get crm() {
|
|
401
|
+
return {
|
|
402
|
+
/**
|
|
403
|
+
* Las personas a las que se les puede asignar una tarea o un lead.
|
|
404
|
+
*
|
|
405
|
+
* **Reenvía lo que conteste**, campos de más incluidos. Recortarlo tú es
|
|
406
|
+
* aplicar dos veces la misma política desde dos sitios que pueden
|
|
407
|
+
* divergir: Pimia esconde aquí a los superadmin de la plataforma y a la
|
|
408
|
+
* gestoría dueña del tenant, y recorta cada fila a `id` y `name`. Si
|
|
409
|
+
* mañana añade un campo para desempatar dos nombres iguales, tu copia lo
|
|
410
|
+
* borraría sin que nadie entendiera por qué.
|
|
411
|
+
*
|
|
412
|
+
* ⚠️ El scope: el contrato publicado la cobra con `crm:read`, pero el
|
|
413
|
+
* núcleo la abrió el 2026-09-08 a cualquier token válido de la empresa
|
|
414
|
+
* —precisamente para que un integrador que SUSTITUYE el CRM no tenga que
|
|
415
|
+
* pedir el scope del CRM que ya no usa—. Contra una instancia anterior a
|
|
416
|
+
* ese cambio sigue haciendo falta `crm:read`.
|
|
417
|
+
*/
|
|
418
|
+
assignableUsers: (options) => this.get('/crm/assignable-users', undefined, options),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* El arranque de la sesión: en qué empresa trabaja este token y con qué
|
|
423
|
+
* moneda.
|
|
424
|
+
*
|
|
425
|
+
* ⛔ **`/bootstrap` NO envuelve en `data`.** Todo lo demás en el API contesta
|
|
426
|
+
* `{ data: … }`; ésta no: sus claves cuelgan de la raíz. Un desenvolvedor de
|
|
427
|
+
* `data` escrito «para todas las llamadas» no encuentra nada aquí y devuelve
|
|
428
|
+
* vacío **sin error**, así que el fallo no se ve como un fallo: se ve como una
|
|
429
|
+
* empresa sin resolver o como una moneda que cae al respaldo. Medido
|
|
430
|
+
* construyendo el CRM de la vertical, que tuvo que anotarlo en su código y en
|
|
431
|
+
* el arnés de sus tests.
|
|
432
|
+
*
|
|
433
|
+
* Lectura libre: la alcanza cualquier token válido, **sin scope** y sin
|
|
434
|
+
* consentimiento adicional del dueño del tenant.
|
|
435
|
+
*
|
|
436
|
+
* ⚠️ Cada método hace SU llamada: no hay caché. Es a propósito —el cliente no
|
|
437
|
+
* sabe cuánto vive una sesión tuya, y una empresa cacheada de más es una fila
|
|
438
|
+
* escrita en la empresa equivocada—, así que si necesitas las dos cosas en la
|
|
439
|
+
* misma petición, llama a `get()` una vez y léelas del objeto.
|
|
440
|
+
*/
|
|
441
|
+
get bootstrap() {
|
|
442
|
+
return {
|
|
443
|
+
/** El arranque entero, sin envolver. */
|
|
444
|
+
get: (options) => this.get('/bootstrap', undefined, options),
|
|
445
|
+
/**
|
|
446
|
+
* En qué empresa trabaja ESTA petición, según el núcleo.
|
|
447
|
+
*
|
|
448
|
+
* ⛔ No es «la primera empresa del usuario», aunque hoy coincidan. Pimia
|
|
449
|
+
* resuelve `current_company` con el mismo camino y el mismo respaldo que
|
|
450
|
+
* usa su middleware de empresa para servir cualquier otra llamada tuya —la
|
|
451
|
+
* cabecera `company` si vale, y si no la primera del usuario—, así que
|
|
452
|
+
* preguntarlo aquí es la única forma de que tu lado y el suyo no puedan
|
|
453
|
+
* discrepar. Deducirlo de la lista de `/me` reproduce la regla en un
|
|
454
|
+
* segundo sitio, y dos reglas iguales son dos reglas que pueden separarse:
|
|
455
|
+
* el día que dejaran de coincidir, escribirías con una empresa que Pimia
|
|
456
|
+
* nunca usó y sin un solo error que lo denuncie.
|
|
457
|
+
*
|
|
458
|
+
* `null` si el arranque no la publica. Trátalo como «no se puede servir
|
|
459
|
+
* esta sesión» y no como un cero: un cero es una empresa que no es de
|
|
460
|
+
* nadie y que ve cualquiera que también acabe ahí.
|
|
461
|
+
*
|
|
462
|
+
* ⚠️ El spec declara `current_company` obligatorio y el tipo generado dice
|
|
463
|
+
* que siempre está; la comprobación de aquí es de RUNTIME porque se ha
|
|
464
|
+
* visto llegar sin ella. Cuando eso pasa, lo que hay que devolver es
|
|
465
|
+
* `null`, no reventar.
|
|
466
|
+
*/
|
|
467
|
+
currentCompanyId: async (options) => {
|
|
468
|
+
const body = await this.get('/bootstrap', undefined, options);
|
|
469
|
+
const id = body.current_company?.id;
|
|
470
|
+
return typeof id === 'number' ? id : null;
|
|
471
|
+
},
|
|
472
|
+
/**
|
|
473
|
+
* La moneda de la empresa y su ESCALA.
|
|
474
|
+
*
|
|
475
|
+
* ⛔ La moneda no es siempre el euro y los decimales cambian con ella: el
|
|
476
|
+
* yen tiene 0, el dinar kuwaití 3. Suponer 2 —o peor, multiplicar por 100
|
|
477
|
+
* a mano— no da un error, da otro resultado: un filtro por importe
|
|
478
|
+
* devuelve otras filas y un alta guarda una moneda falsa en la ficha. Por
|
|
479
|
+
* eso la escala se PREGUNTA.
|
|
480
|
+
*
|
|
481
|
+
* `null` si el arranque no publica moneda (el campo admite nulo en el
|
|
482
|
+
* contrato), y ahí el SDK **no se inventa nada**: «EUR con 2 decimales» es
|
|
483
|
+
* una política de producto, la decide quien llama. Lo que sí se lee a la
|
|
484
|
+
* defensiva es `precision`, que el contrato declara obligatorio dentro de
|
|
485
|
+
* la moneda: un cuerpo sin él está roto, no es un caso de negocio.
|
|
486
|
+
*/
|
|
487
|
+
currency: async (options) => {
|
|
488
|
+
const body = await this.get('/bootstrap', undefined, options);
|
|
489
|
+
const currency = body.current_company_currency;
|
|
490
|
+
if (!currency || typeof currency.code !== 'string')
|
|
491
|
+
return null;
|
|
492
|
+
return {
|
|
493
|
+
code: currency.code,
|
|
494
|
+
precision: typeof currency.precision === 'number' ? currency.precision : 2,
|
|
495
|
+
};
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
297
499
|
get(path, query, options) {
|
|
298
500
|
return this.request(path, { ...options, method: 'GET', query });
|
|
299
501
|
}
|
|
@@ -462,6 +664,14 @@ export class PimiaClient {
|
|
|
462
664
|
async refreshTokens(current) {
|
|
463
665
|
if (this.refreshing)
|
|
464
666
|
return this.refreshing;
|
|
667
|
+
if (this.oauth === null) {
|
|
668
|
+
/* Token prestado: no hay grant propio con el que refrescar. Hoy no se
|
|
669
|
+
llega aquí —sin `refreshToken` el 401 sube tal cual—, y el guardia está
|
|
670
|
+
para que el día que ese camino cambie el error diga lo que pasa en vez
|
|
671
|
+
de reventar contra un `null`. */
|
|
672
|
+
throw new UnauthorizedError(401, 'Este cliente usa un token prestado y no puede refrescarlo: pide uno nuevo a ' +
|
|
673
|
+
'quien te lo prestó.', null);
|
|
674
|
+
}
|
|
465
675
|
if (!current.refreshToken) {
|
|
466
676
|
throw new UnauthorizedError(401, 'El access token caducó y no hay refresh token: vuelve a pedir autorización al usuario.', null);
|
|
467
677
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -32,6 +32,17 @@ export declare class MissingScopeError extends ForbiddenError {
|
|
|
32
32
|
readonly scope: string;
|
|
33
33
|
constructor(scope: string, status: number, message: string, body: unknown, requestId?: string);
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* 403 del plano central (`token_sin_habilidad`): al token personal le falta
|
|
37
|
+
* la habilidad del plano al que llama. `ability` viene del propio cuerpo
|
|
38
|
+
* (`required_ability`): `central` para invitaciones, patrocinio y traspaso;
|
|
39
|
+
* `desarrollador` para `/desarrollador/*`. No se arregla reintentando: hay
|
|
40
|
+
* que acuñar el token con esa habilidad.
|
|
41
|
+
*/
|
|
42
|
+
export declare class MissingAbilityError extends ForbiddenError {
|
|
43
|
+
readonly ability: string;
|
|
44
|
+
constructor(ability: string, status: number, message: string, body: unknown, requestId?: string);
|
|
45
|
+
}
|
|
35
46
|
export declare class NotFoundError extends PimiaApiError {
|
|
36
47
|
}
|
|
37
48
|
/** 422: validación de negocio. `errors` es el mapa campo → mensajes. */
|
package/dist/errors.js
CHANGED
|
@@ -28,6 +28,9 @@ export class PimiaApiError extends PimiaError {
|
|
|
28
28
|
const scope = scopeFrom(message);
|
|
29
29
|
if (scope)
|
|
30
30
|
return new MissingScopeError(scope, status, message, body, requestId);
|
|
31
|
+
const ability = abilityFrom(body);
|
|
32
|
+
if (ability)
|
|
33
|
+
return new MissingAbilityError(ability, status, message, body, requestId);
|
|
31
34
|
return new ForbiddenError(status, message, body, requestId);
|
|
32
35
|
}
|
|
33
36
|
if (status === 422) {
|
|
@@ -63,6 +66,20 @@ export class MissingScopeError extends ForbiddenError {
|
|
|
63
66
|
this.scope = scope;
|
|
64
67
|
}
|
|
65
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* 403 del plano central (`token_sin_habilidad`): al token personal le falta
|
|
71
|
+
* la habilidad del plano al que llama. `ability` viene del propio cuerpo
|
|
72
|
+
* (`required_ability`): `central` para invitaciones, patrocinio y traspaso;
|
|
73
|
+
* `desarrollador` para `/desarrollador/*`. No se arregla reintentando: hay
|
|
74
|
+
* que acuñar el token con esa habilidad.
|
|
75
|
+
*/
|
|
76
|
+
export class MissingAbilityError extends ForbiddenError {
|
|
77
|
+
ability;
|
|
78
|
+
constructor(ability, status, message, body, requestId) {
|
|
79
|
+
super(status, message, body, requestId);
|
|
80
|
+
this.ability = ability;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
66
83
|
export class NotFoundError extends PimiaApiError {
|
|
67
84
|
}
|
|
68
85
|
/** 422: validación de negocio. `errors` es el mapa campo → mensajes. */
|
|
@@ -187,3 +204,10 @@ function duplicateExternalRefFrom(body) {
|
|
|
187
204
|
function scopeFrom(message) {
|
|
188
205
|
return /Token lacks the (\S+) scope/.exec(message)?.[1];
|
|
189
206
|
}
|
|
207
|
+
/** `required_ability` del 403 `token_sin_habilidad` del plano central. */
|
|
208
|
+
function abilityFrom(body) {
|
|
209
|
+
const b = body;
|
|
210
|
+
if (b?.error !== 'token_sin_habilidad')
|
|
211
|
+
return undefined;
|
|
212
|
+
return typeof b.required_ability === 'string' && b.required_ability !== '' ? b.required_ability : undefined;
|
|
213
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
* salen los tipos de `./api`.
|
|
7
7
|
*/
|
|
8
8
|
export { PimiaClient, toFormData } from './client.js';
|
|
9
|
-
export type { ContractRequest, ContractResource, CustomerRequest, CustomerResource, EstimateResource, EstimatesRequest, InvoiceResource, InvoicesRequest, ItemWarehouseStockResource, PimiaClientOptions, RateLimit, ReadOptions, RequestOptions, ResourceEnvelope, ResponseMeta, ResponseWithMeta, StockCountRequest, StockCountResource, WarehouseRequest, WarehouseResource, WriteOptions, } from './client.js';
|
|
9
|
+
export type { BorrowedTokenOptions, ContractRequest, ContractResource, CustomerRequest, CustomerResource, EstimateResource, EstimatesRequest, InvoiceResource, InvoicesRequest, ItemWarehouseStockResource, OpportunityRequest, OpportunityResource, PimiaClientOptions, RateLimit, ReadOptions, RequestOptions, ResourceEnvelope, ResponseMeta, ResponseWithMeta, StockCountRequest, StockCountResource, WarehouseRequest, WarehouseResource, WriteOptions, } from './client.js';
|
|
10
|
+
export { PimiaCentralClient } from './central.js';
|
|
11
|
+
export type { ActivacionMayoristaRequest, CatalogoDelIntegradorRequest, CentralRequestOptions, CentralResponseMeta, CentralResponseWithMeta, IntegradorDominioRequest, IntegradorTokenRequest, PimiaCentralClientOptions, SponsorshipRequest, BillingPortalRequest, TenantInvitationRequest, TransferOwnershipRequest, } from './central.js';
|
|
10
12
|
export { OAuth, createPkceChallenge, createState } from './oauth.js';
|
|
11
13
|
export type { AuthorizationServerMetadata, AuthorizeUrlOptions, OAuthConfig, PkceChallenge, } from './oauth.js';
|
|
12
|
-
export { MemoryTokenStore, isExpired, tokenSetFromResponse } from './tokens.js';
|
|
14
|
+
export { BorrowedTokenStore, MemoryTokenStore, isExpired, tokenSetFromResponse } from './tokens.js';
|
|
13
15
|
export type { TokenSet, TokenStore } from './tokens.js';
|
|
14
|
-
export { DuplicateExternalRefError, ForbiddenError, MissingScopeError, NotAuthenticatedError, NotFoundError, OAuthError, PimiaApiError, PimiaError, RateLimitError, UnauthorizedError, ValidationError, } from './errors.js';
|
|
16
|
+
export { DuplicateExternalRefError, ForbiddenError, MissingAbilityError, MissingScopeError, NotAuthenticatedError, NotFoundError, OAuthError, PimiaApiError, PimiaError, RateLimitError, UnauthorizedError, ValidationError, } from './errors.js';
|
|
15
17
|
export { WEBHOOK_DEFAULT_TOLERANCE_SECONDS, WEBHOOK_EVENTS, WEBHOOK_HEADERS, WEBHOOK_SIGNATURE_VERSION, WebhookVerificationError, isWebhookEvent, signWebhook, verifyWebhook, } from './webhooks.js';
|
|
16
18
|
export type { ApprovalDecidedPayload, AppRevokedPayload, CustomerPayload, EstimateAcceptedPayload, ExternalRef, InvoiceCreatedPayload, InvoicePaidPayload, InvoiceReceivedPayload, IsoDateTime, KnownWebhook, PimiaWebhook, SignWebhookOptions, UnknownWebhook, VerifyWebhookOptions, WebhookBodyInput, WebhookEvent, WebhookHeadersInput, WebhookPayloads, WebhookVerificationReason, } from './webhooks.js';
|
|
17
19
|
/** Scopes granulares del catálogo de Pimia (paso 4). Pide siempre lo mínimo. */
|
|
@@ -52,6 +54,24 @@ export declare const SCOPES: {
|
|
|
52
54
|
readonly workRead: "work:read";
|
|
53
55
|
/** Crear y modificar obras y proyectos, tareas y partes de horas. */
|
|
54
56
|
readonly workWrite: "work:write";
|
|
57
|
+
/**
|
|
58
|
+
* Leer la campana: tareas vencidas, ausencias y correcciones de fichaje.
|
|
59
|
+
*
|
|
60
|
+
* ⚠️ Desde la 0.21.0 `/notifications` tiene dominio propio y `crm:read` ya
|
|
61
|
+
* no lo alcanza (núcleo galeote/factSaas#677). Lo que la campana lleva lo
|
|
62
|
+
* emiten los módulos de Trabajo y de Personal, no el embudo comercial.
|
|
63
|
+
*/
|
|
64
|
+
readonly notificationsRead: "notifications:read";
|
|
65
|
+
/** Marcar avisos como leídos y borrarlos. */
|
|
66
|
+
readonly notificationsWrite: "notifications:write";
|
|
67
|
+
/**
|
|
68
|
+
* Leer el catálogo de apps integradas y qué tiene instalada la empresa
|
|
69
|
+
* (`/apps`, `/apps/{slug}`, `/apps/{slug}/config`). Entra en la 0.22.0 con
|
|
70
|
+
* la fase 1 de apps integradas del núcleo (galeote/factSaas#697-#702).
|
|
71
|
+
*/
|
|
72
|
+
readonly appsRead: "apps:read";
|
|
73
|
+
/** Instalar y desinstalar una app en la empresa, ajustar su configuración y acuñar su credencial de entrada. */
|
|
74
|
+
readonly appsWrite: "apps:write";
|
|
55
75
|
readonly agendaRead: "agenda:read";
|
|
56
76
|
readonly agendaWrite: "agenda:write";
|
|
57
77
|
readonly reportsRead: "reports:read";
|
package/dist/index.js
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* salen los tipos de `./api`.
|
|
7
7
|
*/
|
|
8
8
|
export { PimiaClient, toFormData } from './client.js';
|
|
9
|
+
export { PimiaCentralClient } from './central.js';
|
|
9
10
|
export { OAuth, createPkceChallenge, createState } from './oauth.js';
|
|
10
|
-
export { MemoryTokenStore, isExpired, tokenSetFromResponse } from './tokens.js';
|
|
11
|
-
export { DuplicateExternalRefError, ForbiddenError, MissingScopeError, NotAuthenticatedError, NotFoundError, OAuthError, PimiaApiError, PimiaError, RateLimitError, UnauthorizedError, ValidationError, } from './errors.js';
|
|
11
|
+
export { BorrowedTokenStore, MemoryTokenStore, isExpired, tokenSetFromResponse } from './tokens.js';
|
|
12
|
+
export { DuplicateExternalRefError, ForbiddenError, MissingAbilityError, MissingScopeError, NotAuthenticatedError, NotFoundError, OAuthError, PimiaApiError, PimiaError, RateLimitError, UnauthorizedError, ValidationError, } from './errors.js';
|
|
12
13
|
export { WEBHOOK_DEFAULT_TOLERANCE_SECONDS, WEBHOOK_EVENTS, WEBHOOK_HEADERS, WEBHOOK_SIGNATURE_VERSION, WebhookVerificationError, isWebhookEvent, signWebhook, verifyWebhook, } from './webhooks.js';
|
|
13
14
|
/** Scopes granulares del catálogo de Pimia (paso 4). Pide siempre lo mínimo. */
|
|
14
15
|
export const SCOPES = {
|
|
@@ -48,6 +49,24 @@ export const SCOPES = {
|
|
|
48
49
|
workRead: 'work:read',
|
|
49
50
|
/** Crear y modificar obras y proyectos, tareas y partes de horas. */
|
|
50
51
|
workWrite: 'work:write',
|
|
52
|
+
/**
|
|
53
|
+
* Leer la campana: tareas vencidas, ausencias y correcciones de fichaje.
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ Desde la 0.21.0 `/notifications` tiene dominio propio y `crm:read` ya
|
|
56
|
+
* no lo alcanza (núcleo galeote/factSaas#677). Lo que la campana lleva lo
|
|
57
|
+
* emiten los módulos de Trabajo y de Personal, no el embudo comercial.
|
|
58
|
+
*/
|
|
59
|
+
notificationsRead: 'notifications:read',
|
|
60
|
+
/** Marcar avisos como leídos y borrarlos. */
|
|
61
|
+
notificationsWrite: 'notifications:write',
|
|
62
|
+
/**
|
|
63
|
+
* Leer el catálogo de apps integradas y qué tiene instalada la empresa
|
|
64
|
+
* (`/apps`, `/apps/{slug}`, `/apps/{slug}/config`). Entra en la 0.22.0 con
|
|
65
|
+
* la fase 1 de apps integradas del núcleo (galeote/factSaas#697-#702).
|
|
66
|
+
*/
|
|
67
|
+
appsRead: 'apps:read',
|
|
68
|
+
/** Instalar y desinstalar una app en la empresa, ajustar su configuración y acuñar su credencial de entrada. */
|
|
69
|
+
appsWrite: 'apps:write',
|
|
51
70
|
agendaRead: 'agenda:read',
|
|
52
71
|
agendaWrite: 'agenda:write',
|
|
53
72
|
reportsRead: 'reports:read',
|
package/dist/tokens.d.ts
CHANGED
|
@@ -20,6 +20,12 @@ export interface TokenSet {
|
|
|
20
20
|
expiresAt?: number;
|
|
21
21
|
scope?: string;
|
|
22
22
|
tokenType?: string;
|
|
23
|
+
/**
|
|
24
|
+
* A qué instancia pertenece el token. Lo dice el canje del AS del ÁPICE
|
|
25
|
+
* (`tenant_id`), donde la app no lo sabe por el host; el AS de un tenant no
|
|
26
|
+
* lo manda (ahí el host ya lo dice).
|
|
27
|
+
*/
|
|
28
|
+
tenantId?: string;
|
|
23
29
|
}
|
|
24
30
|
export interface TokenStore {
|
|
25
31
|
load(): Promise<TokenSet | null> | TokenSet | null;
|
|
@@ -34,6 +40,44 @@ export declare class MemoryTokenStore implements TokenStore {
|
|
|
34
40
|
save(tokens: TokenSet): void;
|
|
35
41
|
clear(): void;
|
|
36
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* El «store» de un token que NO es tuyo.
|
|
45
|
+
*
|
|
46
|
+
* ── Para qué existe ─────────────────────────────────────────────────────────
|
|
47
|
+
*
|
|
48
|
+
* Hay integraciones que no poseen ningún grant y **no deben poseerlo**: un
|
|
49
|
+
* servicio al que el front le manda, en cada petición, el `Authorization` del
|
|
50
|
+
* usuario que ha entrado en Pimia, y que lo reenvía tal cual. La consecuencia
|
|
51
|
+
* buena es que Pimia sigue decidiendo los permisos: ese servicio no puede darle
|
|
52
|
+
* a nadie más de lo que su token ya le daba, y no hay una credencial propia que
|
|
53
|
+
* auditar aparte.
|
|
54
|
+
*
|
|
55
|
+
* Un token prestado **no trae refresh** —el refresh es del dueño del grant— y
|
|
56
|
+
* dura lo que dure la petición. Eso hace que todo lo que este SDK protege del
|
|
57
|
+
* {@link TokenStore} de verdad no aplique aquí: no hay rotación que persistir,
|
|
58
|
+
* ni reuse que evitar, ni candado por usuario que sostener.
|
|
59
|
+
*
|
|
60
|
+
* ── Por qué `save()` REVIENTA en vez de callar ──────────────────────────────
|
|
61
|
+
*
|
|
62
|
+
* Porque llegar ahí significaría que el cliente ha creído refrescar un token
|
|
63
|
+
* ajeno. Hoy no puede pasar por construcción —sin `refreshToken` el 401 sube
|
|
64
|
+
* tal cual en vez de disparar un refresco—, y justo por eso el día que alguien
|
|
65
|
+
* cambie ese camino conviene que se entere aquí, con el nombre de la clase
|
|
66
|
+
* dentro, y no en producción como un grant de otro revocado en cascada.
|
|
67
|
+
*
|
|
68
|
+
* No se construye a mano: sale de `PimiaClient.withBorrowedToken()`.
|
|
69
|
+
*/
|
|
70
|
+
export declare class BorrowedTokenStore implements TokenStore {
|
|
71
|
+
private readonly accessToken;
|
|
72
|
+
constructor(accessToken: string);
|
|
73
|
+
load(): TokenSet;
|
|
74
|
+
save(): void;
|
|
75
|
+
/**
|
|
76
|
+
* No-op, y no es pereza: no hay nada que borrar. El token vive en la petición
|
|
77
|
+
* que lo trajo y muere con ella.
|
|
78
|
+
*/
|
|
79
|
+
clear(): void;
|
|
80
|
+
}
|
|
37
81
|
/** ¿Caduca dentro de `skewSeconds`? Sin expiresAt se asume que sigue vivo. */
|
|
38
82
|
export declare function isExpired(tokens: TokenSet, skewSeconds?: number, now?: number): boolean;
|
|
39
83
|
/** Respuesta cruda del token endpoint → TokenSet. */
|
|
@@ -43,4 +87,5 @@ export declare function tokenSetFromResponse(payload: {
|
|
|
43
87
|
expires_in?: number;
|
|
44
88
|
scope?: string;
|
|
45
89
|
token_type?: string;
|
|
90
|
+
tenant_id?: string;
|
|
46
91
|
}, now?: number): TokenSet;
|
package/dist/tokens.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* procesos pueden refrescar a la vez, serializa el refresh — dos refrescos
|
|
13
13
|
* concurrentes con el mismo token son, para el servidor, un reuse.
|
|
14
14
|
*/
|
|
15
|
+
import { PimiaError } from './errors.js';
|
|
15
16
|
/** Store de memoria: vale para scripts y tests, NO para producción con varios procesos. */
|
|
16
17
|
export class MemoryTokenStore {
|
|
17
18
|
tokens;
|
|
@@ -28,6 +29,55 @@ export class MemoryTokenStore {
|
|
|
28
29
|
this.tokens = null;
|
|
29
30
|
}
|
|
30
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* El «store» de un token que NO es tuyo.
|
|
34
|
+
*
|
|
35
|
+
* ── Para qué existe ─────────────────────────────────────────────────────────
|
|
36
|
+
*
|
|
37
|
+
* Hay integraciones que no poseen ningún grant y **no deben poseerlo**: un
|
|
38
|
+
* servicio al que el front le manda, en cada petición, el `Authorization` del
|
|
39
|
+
* usuario que ha entrado en Pimia, y que lo reenvía tal cual. La consecuencia
|
|
40
|
+
* buena es que Pimia sigue decidiendo los permisos: ese servicio no puede darle
|
|
41
|
+
* a nadie más de lo que su token ya le daba, y no hay una credencial propia que
|
|
42
|
+
* auditar aparte.
|
|
43
|
+
*
|
|
44
|
+
* Un token prestado **no trae refresh** —el refresh es del dueño del grant— y
|
|
45
|
+
* dura lo que dure la petición. Eso hace que todo lo que este SDK protege del
|
|
46
|
+
* {@link TokenStore} de verdad no aplique aquí: no hay rotación que persistir,
|
|
47
|
+
* ni reuse que evitar, ni candado por usuario que sostener.
|
|
48
|
+
*
|
|
49
|
+
* ── Por qué `save()` REVIENTA en vez de callar ──────────────────────────────
|
|
50
|
+
*
|
|
51
|
+
* Porque llegar ahí significaría que el cliente ha creído refrescar un token
|
|
52
|
+
* ajeno. Hoy no puede pasar por construcción —sin `refreshToken` el 401 sube
|
|
53
|
+
* tal cual en vez de disparar un refresco—, y justo por eso el día que alguien
|
|
54
|
+
* cambie ese camino conviene que se entere aquí, con el nombre de la clase
|
|
55
|
+
* dentro, y no en producción como un grant de otro revocado en cascada.
|
|
56
|
+
*
|
|
57
|
+
* No se construye a mano: sale de `PimiaClient.withBorrowedToken()`.
|
|
58
|
+
*/
|
|
59
|
+
export class BorrowedTokenStore {
|
|
60
|
+
accessToken;
|
|
61
|
+
constructor(accessToken) {
|
|
62
|
+
this.accessToken = accessToken;
|
|
63
|
+
}
|
|
64
|
+
load() {
|
|
65
|
+
/* Sin `refreshToken` y sin `expiresAt`: los dos son del dueño del grant.
|
|
66
|
+
Sin expiración el cliente no intenta refrescar por su cuenta, y sin
|
|
67
|
+
refresh un 401 sube tal cual — que es lo correcto: quien tiene que
|
|
68
|
+
conseguir otro token es quien te prestó éste. */
|
|
69
|
+
return { accessToken: this.accessToken };
|
|
70
|
+
}
|
|
71
|
+
save() {
|
|
72
|
+
throw new PimiaError('Este cliente usa un token prestado: no hay grant propio que rotar ni nada que ' +
|
|
73
|
+
'persistir. Si has llegado aquí, alguien ha intentado refrescar el token de otro.');
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* No-op, y no es pereza: no hay nada que borrar. El token vive en la petición
|
|
77
|
+
* que lo trajo y muere con ella.
|
|
78
|
+
*/
|
|
79
|
+
clear() { }
|
|
80
|
+
}
|
|
31
81
|
/** ¿Caduca dentro de `skewSeconds`? Sin expiresAt se asume que sigue vivo. */
|
|
32
82
|
export function isExpired(tokens, skewSeconds = 60, now = Date.now()) {
|
|
33
83
|
if (tokens.expiresAt === undefined)
|
|
@@ -42,5 +92,8 @@ export function tokenSetFromResponse(payload, now = Date.now()) {
|
|
|
42
92
|
expiresAt: payload.expires_in ? now + payload.expires_in * 1000 : undefined,
|
|
43
93
|
scope: payload.scope,
|
|
44
94
|
tokenType: payload.token_type ?? 'bearer',
|
|
95
|
+
...(typeof payload.tenant_id === 'string' && payload.tenant_id !== ''
|
|
96
|
+
? { tenantId: payload.tenant_id }
|
|
97
|
+
: {}),
|
|
45
98
|
};
|
|
46
99
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pimia/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Cliente TypeScript de la API de Pimia para apps de partner: OAuth con PKCE, rotación de refresh persistida, reintentos de rate limit y tipos generados del OpenAPI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Pimia (https://pimia.es)",
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
"./api": {
|
|
29
29
|
"types": "./dist/api.d.ts",
|
|
30
30
|
"import": "./dist/api.js"
|
|
31
|
+
},
|
|
32
|
+
"./central-api": {
|
|
33
|
+
"types": "./dist/central-api.d.ts",
|
|
34
|
+
"import": "./dist/central-api.js"
|
|
31
35
|
}
|
|
32
36
|
},
|
|
33
37
|
"files": [
|