@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/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
- this.oauth = new OAuth(options);
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.20.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": [