@pimia/sdk 0.1.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/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/api.d.ts +13463 -0
- package/dist/api.js +5 -0
- package/dist/client.d.ts +96 -0
- package/dist/client.js +232 -0
- package/dist/errors.d.ts +57 -0
- package/dist/errors.js +109 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +33 -0
- package/dist/oauth.d.ts +83 -0
- package/dist/oauth.js +147 -0
- package/dist/tokens.d.ts +46 -0
- package/dist/tokens.js +46 -0
- package/package.json +58 -0
package/dist/api.js
ADDED
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cliente HTTP de la API de Pimia.
|
|
3
|
+
*
|
|
4
|
+
* Lo que resuelve por ti, que es justo donde se equivoca una integración
|
|
5
|
+
* escrita a mano:
|
|
6
|
+
*
|
|
7
|
+
* - **rotación de refresh**: refresca al detectar caducidad o un 401, y
|
|
8
|
+
* persiste el TokenSet nuevo en tu TokenStore antes de reintentar;
|
|
9
|
+
* - **un solo refresh a la vez** dentro del proceso: dos refrescos paralelos
|
|
10
|
+
* con el mismo token son un reuse para el servidor, y un reuse revoca el
|
|
11
|
+
* grant entero. Entre procesos, esto no basta: usa un store compartido con
|
|
12
|
+
* su propio candado;
|
|
13
|
+
* - **429**: respeta `Retry-After` y reintenta con espera acotada;
|
|
14
|
+
* - **errores tipados**: MissingScopeError trae el scope exacto que falta.
|
|
15
|
+
*/
|
|
16
|
+
import { OAuth, type OAuthConfig } from './oauth.js';
|
|
17
|
+
import { type TokenStore } from './tokens.js';
|
|
18
|
+
export interface PimiaClientOptions extends OAuthConfig {
|
|
19
|
+
tokens: TokenStore;
|
|
20
|
+
/** Segundos de margen para refrescar antes de que caduque (default 60). */
|
|
21
|
+
expirySkewSeconds?: number;
|
|
22
|
+
/** Reintentos ante 429 (default 2). */
|
|
23
|
+
maxRateLimitRetries?: number;
|
|
24
|
+
/** Espera máxima por reintento de 429, en ms (default 30 000). */
|
|
25
|
+
maxRetryDelayMs?: number;
|
|
26
|
+
/** Cabeceras añadidas a cada petición (p. ej. un User-Agent propio). */
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
export interface RequestOptions {
|
|
30
|
+
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
31
|
+
/** Query string. Los `undefined`/`null` se omiten; los arrays se repiten. */
|
|
32
|
+
query?: Record<string, string | number | boolean | undefined | null | Array<string | number>>;
|
|
33
|
+
/** Cuerpo JSON. */
|
|
34
|
+
body?: unknown;
|
|
35
|
+
headers?: Record<string, string>;
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
}
|
|
38
|
+
/** Cabeceras de rate limit que devuelve la API en cada respuesta. */
|
|
39
|
+
export interface RateLimit {
|
|
40
|
+
limit?: number;
|
|
41
|
+
remaining?: number;
|
|
42
|
+
}
|
|
43
|
+
export declare class PimiaClient {
|
|
44
|
+
readonly oauth: OAuth;
|
|
45
|
+
private readonly baseUrl;
|
|
46
|
+
private readonly doFetch;
|
|
47
|
+
private readonly store;
|
|
48
|
+
private readonly skew;
|
|
49
|
+
private readonly maxRateLimitRetries;
|
|
50
|
+
private readonly maxRetryDelayMs;
|
|
51
|
+
private readonly extraHeaders;
|
|
52
|
+
/** Refresco en vuelo: cualquier petición que llegue mientras tanto lo espera. */
|
|
53
|
+
private refreshing;
|
|
54
|
+
private lastRateLimit;
|
|
55
|
+
constructor(options: PimiaClientOptions);
|
|
56
|
+
/** Cabeceras `X-RateLimit-*` de la última respuesta. */
|
|
57
|
+
get rateLimit(): RateLimit;
|
|
58
|
+
get invoices(): {
|
|
59
|
+
list: (query?: RequestOptions["query"]) => Promise<unknown>;
|
|
60
|
+
get: (id: number | string) => Promise<unknown>;
|
|
61
|
+
create: (body: unknown) => Promise<unknown>;
|
|
62
|
+
update: (id: number | string, body: unknown) => Promise<unknown>;
|
|
63
|
+
};
|
|
64
|
+
get customers(): {
|
|
65
|
+
list: (query?: RequestOptions["query"]) => Promise<unknown>;
|
|
66
|
+
get: (id: number | string) => Promise<unknown>;
|
|
67
|
+
create: (body: unknown) => Promise<unknown>;
|
|
68
|
+
update: (id: number | string, body: unknown) => Promise<unknown>;
|
|
69
|
+
};
|
|
70
|
+
get estimates(): {
|
|
71
|
+
list: (query?: RequestOptions["query"]) => Promise<unknown>;
|
|
72
|
+
get: (id: number | string) => Promise<unknown>;
|
|
73
|
+
create: (body: unknown) => Promise<unknown>;
|
|
74
|
+
};
|
|
75
|
+
get<T = unknown>(path: string, query?: RequestOptions['query']): Promise<T>;
|
|
76
|
+
post<T = unknown>(path: string, body?: unknown): Promise<T>;
|
|
77
|
+
put<T = unknown>(path: string, body?: unknown): Promise<T>;
|
|
78
|
+
patch<T = unknown>(path: string, body?: unknown): Promise<T>;
|
|
79
|
+
delete<T = unknown>(path: string): Promise<T>;
|
|
80
|
+
/**
|
|
81
|
+
* Petición cruda contra `/api/v1`. `path` puede llevar el prefijo o no:
|
|
82
|
+
* `/invoices` y `/api/v1/invoices` son lo mismo.
|
|
83
|
+
*/
|
|
84
|
+
request<T = unknown>(path: string, options?: RequestOptions): Promise<T>;
|
|
85
|
+
private currentTokens;
|
|
86
|
+
/**
|
|
87
|
+
* Refresca UNA sola vez aunque lo pidan N peticiones en paralelo, y persiste
|
|
88
|
+
* el resultado. Sin esta serialización, dos peticiones caducadas a la vez
|
|
89
|
+
* canjearían el mismo refresh y el servidor lo leería como reuse → grant
|
|
90
|
+
* revocado en cascada.
|
|
91
|
+
*/
|
|
92
|
+
private refreshTokens;
|
|
93
|
+
private urlFor;
|
|
94
|
+
private captureRateLimit;
|
|
95
|
+
private retryDelay;
|
|
96
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cliente HTTP de la API de Pimia.
|
|
3
|
+
*
|
|
4
|
+
* Lo que resuelve por ti, que es justo donde se equivoca una integración
|
|
5
|
+
* escrita a mano:
|
|
6
|
+
*
|
|
7
|
+
* - **rotación de refresh**: refresca al detectar caducidad o un 401, y
|
|
8
|
+
* persiste el TokenSet nuevo en tu TokenStore antes de reintentar;
|
|
9
|
+
* - **un solo refresh a la vez** dentro del proceso: dos refrescos paralelos
|
|
10
|
+
* con el mismo token son un reuse para el servidor, y un reuse revoca el
|
|
11
|
+
* grant entero. Entre procesos, esto no basta: usa un store compartido con
|
|
12
|
+
* su propio candado;
|
|
13
|
+
* - **429**: respeta `Retry-After` y reintenta con espera acotada;
|
|
14
|
+
* - **errores tipados**: MissingScopeError trae el scope exacto que falta.
|
|
15
|
+
*/
|
|
16
|
+
import { NotAuthenticatedError, OAuthError, PimiaApiError, RateLimitError, UnauthorizedError, } from './errors.js';
|
|
17
|
+
import { OAuth } from './oauth.js';
|
|
18
|
+
import { isExpired } from './tokens.js';
|
|
19
|
+
export class PimiaClient {
|
|
20
|
+
oauth;
|
|
21
|
+
baseUrl;
|
|
22
|
+
doFetch;
|
|
23
|
+
store;
|
|
24
|
+
skew;
|
|
25
|
+
maxRateLimitRetries;
|
|
26
|
+
maxRetryDelayMs;
|
|
27
|
+
extraHeaders;
|
|
28
|
+
/** Refresco en vuelo: cualquier petición que llegue mientras tanto lo espera. */
|
|
29
|
+
refreshing = null;
|
|
30
|
+
lastRateLimit = {};
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.oauth = new OAuth(options);
|
|
33
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
|
34
|
+
this.doFetch = options.fetch ?? globalThis.fetch;
|
|
35
|
+
this.store = options.tokens;
|
|
36
|
+
this.skew = options.expirySkewSeconds ?? 60;
|
|
37
|
+
this.maxRateLimitRetries = options.maxRateLimitRetries ?? 2;
|
|
38
|
+
this.maxRetryDelayMs = options.maxRetryDelayMs ?? 30_000;
|
|
39
|
+
this.extraHeaders = options.headers ?? {};
|
|
40
|
+
}
|
|
41
|
+
/** Cabeceras `X-RateLimit-*` de la última respuesta. */
|
|
42
|
+
get rateLimit() {
|
|
43
|
+
return { ...this.lastRateLimit };
|
|
44
|
+
}
|
|
45
|
+
get invoices() {
|
|
46
|
+
return {
|
|
47
|
+
list: (query) => this.get('/invoices', query),
|
|
48
|
+
get: (id) => this.get(`/invoices/${id}`),
|
|
49
|
+
create: (body) => this.post('/invoices', body),
|
|
50
|
+
update: (id, body) => this.put(`/invoices/${id}`, body),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
get customers() {
|
|
54
|
+
return {
|
|
55
|
+
list: (query) => this.get('/customers', query),
|
|
56
|
+
get: (id) => this.get(`/customers/${id}`),
|
|
57
|
+
create: (body) => this.post('/customers', body),
|
|
58
|
+
update: (id, body) => this.put(`/customers/${id}`, body),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
get estimates() {
|
|
62
|
+
return {
|
|
63
|
+
list: (query) => this.get('/estimates', query),
|
|
64
|
+
get: (id) => this.get(`/estimates/${id}`),
|
|
65
|
+
create: (body) => this.post('/estimates', body),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
get(path, query) {
|
|
69
|
+
return this.request(path, { method: 'GET', query });
|
|
70
|
+
}
|
|
71
|
+
post(path, body) {
|
|
72
|
+
return this.request(path, { method: 'POST', body });
|
|
73
|
+
}
|
|
74
|
+
put(path, body) {
|
|
75
|
+
return this.request(path, { method: 'PUT', body });
|
|
76
|
+
}
|
|
77
|
+
patch(path, body) {
|
|
78
|
+
return this.request(path, { method: 'PATCH', body });
|
|
79
|
+
}
|
|
80
|
+
delete(path) {
|
|
81
|
+
return this.request(path, { method: 'DELETE' });
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Petición cruda contra `/api/v1`. `path` puede llevar el prefijo o no:
|
|
85
|
+
* `/invoices` y `/api/v1/invoices` son lo mismo.
|
|
86
|
+
*/
|
|
87
|
+
async request(path, options = {}) {
|
|
88
|
+
let tokens = await this.currentTokens();
|
|
89
|
+
if (isExpired(tokens, this.skew)) {
|
|
90
|
+
tokens = await this.refreshTokens(tokens);
|
|
91
|
+
}
|
|
92
|
+
let attempt = 0;
|
|
93
|
+
let refreshedOn401 = false;
|
|
94
|
+
for (;;) {
|
|
95
|
+
const response = await this.doFetch(this.urlFor(path, options.query), {
|
|
96
|
+
method: options.method ?? 'GET',
|
|
97
|
+
headers: {
|
|
98
|
+
accept: 'application/json',
|
|
99
|
+
...(options.body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
100
|
+
...this.extraHeaders,
|
|
101
|
+
...options.headers,
|
|
102
|
+
authorization: `Bearer ${tokens.accessToken}`,
|
|
103
|
+
},
|
|
104
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
105
|
+
signal: options.signal,
|
|
106
|
+
});
|
|
107
|
+
this.captureRateLimit(response);
|
|
108
|
+
if (response.ok) {
|
|
109
|
+
return (await parseBody(response));
|
|
110
|
+
}
|
|
111
|
+
const body = await parseBody(response);
|
|
112
|
+
const requestId = response.headers.get('x-request-id') ?? undefined;
|
|
113
|
+
// 401: un intento de refresco y se reintenta. Si el usuario revocó la
|
|
114
|
+
// app, el refresh también falla y el error sube tal cual — hay que
|
|
115
|
+
// volver a pedirle autorización.
|
|
116
|
+
if (response.status === 401 && !refreshedOn401 && tokens.refreshToken) {
|
|
117
|
+
refreshedOn401 = true;
|
|
118
|
+
tokens = await this.refreshTokens(tokens);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (response.status === 429 && attempt < this.maxRateLimitRetries) {
|
|
122
|
+
attempt++;
|
|
123
|
+
await sleep(this.retryDelay(response, attempt));
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (response.status === 429) {
|
|
127
|
+
throw new RateLimitError(retryAfterSeconds(response), 429, 'Rate limit alcanzado', body, requestId);
|
|
128
|
+
}
|
|
129
|
+
throw PimiaApiError.from(response.status, body, requestId);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async currentTokens() {
|
|
133
|
+
const tokens = await this.store.load();
|
|
134
|
+
if (!tokens?.accessToken) {
|
|
135
|
+
throw new NotAuthenticatedError('No hay tokens en el TokenStore: completa el flujo de autorización antes de llamar a la API.');
|
|
136
|
+
}
|
|
137
|
+
return tokens;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Refresca UNA sola vez aunque lo pidan N peticiones en paralelo, y persiste
|
|
141
|
+
* el resultado. Sin esta serialización, dos peticiones caducadas a la vez
|
|
142
|
+
* canjearían el mismo refresh y el servidor lo leería como reuse → grant
|
|
143
|
+
* revocado en cascada.
|
|
144
|
+
*/
|
|
145
|
+
async refreshTokens(current) {
|
|
146
|
+
if (this.refreshing)
|
|
147
|
+
return this.refreshing;
|
|
148
|
+
if (!current.refreshToken) {
|
|
149
|
+
throw new UnauthorizedError(401, 'El access token caducó y no hay refresh token: vuelve a pedir autorización al usuario.', null);
|
|
150
|
+
}
|
|
151
|
+
this.refreshing = (async () => {
|
|
152
|
+
try {
|
|
153
|
+
const rotated = await this.oauth.refresh(current.refreshToken);
|
|
154
|
+
await this.store.save(rotated);
|
|
155
|
+
return rotated;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
// Un refresco fallido significa siempre lo mismo para quien llama:
|
|
159
|
+
// este grant ya no vale y hay que volver a pedir autorización al
|
|
160
|
+
// usuario (revocó la app, caducó el refresh, o se reusó uno rotado).
|
|
161
|
+
// Se traduce a UnauthorizedError para que un solo `catch` cubra el
|
|
162
|
+
// caso: sin esto, el error del token endpoint (OAuthError
|
|
163
|
+
// invalid_grant) se colaba por debajo del contrato del cliente —
|
|
164
|
+
// detectado en el e2e real contra dev al revocar desde el panel.
|
|
165
|
+
if (error instanceof OAuthError) {
|
|
166
|
+
const unauthorized = new UnauthorizedError(401, `No se pudo refrescar el token (${error.error}): vuelve a pedir autorización al usuario.`, null);
|
|
167
|
+
unauthorized.cause = error;
|
|
168
|
+
throw unauthorized;
|
|
169
|
+
}
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
this.refreshing = null;
|
|
174
|
+
}
|
|
175
|
+
})();
|
|
176
|
+
return this.refreshing;
|
|
177
|
+
}
|
|
178
|
+
urlFor(path, query) {
|
|
179
|
+
const clean = path.replace(/^\/+/, '').replace(/^api\/v1\/?/, '');
|
|
180
|
+
const url = new URL(`${this.baseUrl}/api/v1/${clean}`);
|
|
181
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
182
|
+
if (value === undefined || value === null)
|
|
183
|
+
continue;
|
|
184
|
+
if (Array.isArray(value)) {
|
|
185
|
+
for (const item of value)
|
|
186
|
+
url.searchParams.append(`${key}[]`, String(item));
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
url.searchParams.set(key, String(value));
|
|
190
|
+
}
|
|
191
|
+
return url.toString();
|
|
192
|
+
}
|
|
193
|
+
captureRateLimit(response) {
|
|
194
|
+
const limit = response.headers.get('x-ratelimit-limit');
|
|
195
|
+
const remaining = response.headers.get('x-ratelimit-remaining');
|
|
196
|
+
this.lastRateLimit = {
|
|
197
|
+
limit: limit === null ? undefined : Number(limit),
|
|
198
|
+
remaining: remaining === null ? undefined : Number(remaining),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
retryDelay(response, attempt) {
|
|
202
|
+
const retryAfter = retryAfterSeconds(response);
|
|
203
|
+
const base = retryAfter !== undefined ? retryAfter * 1000 : 2 ** attempt * 500;
|
|
204
|
+
return Math.min(base, this.maxRetryDelayMs);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function retryAfterSeconds(response) {
|
|
208
|
+
const header = response.headers.get('retry-after');
|
|
209
|
+
if (header === null)
|
|
210
|
+
return undefined;
|
|
211
|
+
const seconds = Number(header);
|
|
212
|
+
return Number.isFinite(seconds) ? seconds : undefined;
|
|
213
|
+
}
|
|
214
|
+
async function parseBody(response) {
|
|
215
|
+
if (response.status === 204)
|
|
216
|
+
return null;
|
|
217
|
+
const text = await response.text();
|
|
218
|
+
if (text === '')
|
|
219
|
+
return null;
|
|
220
|
+
const type = response.headers.get('content-type') ?? '';
|
|
221
|
+
if (!type.includes('json'))
|
|
222
|
+
return text;
|
|
223
|
+
try {
|
|
224
|
+
return JSON.parse(text);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return text;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function sleep(ms) {
|
|
231
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
232
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errores del SDK. La API de Pimia tiene un contrato de errores estable
|
|
3
|
+
* (docs/guia-integradores.md §7) y merece tipos propios: un 403 por scope no
|
|
4
|
+
* se arregla reintentando, un 422 tampoco, y un 429 sí.
|
|
5
|
+
*/
|
|
6
|
+
export declare class PimiaError extends Error {
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
9
|
+
/** Respuesta HTTP de error de la API. */
|
|
10
|
+
export declare class PimiaApiError extends PimiaError {
|
|
11
|
+
readonly status: number;
|
|
12
|
+
readonly body: unknown;
|
|
13
|
+
readonly requestId?: string | undefined;
|
|
14
|
+
constructor(status: number, message: string, body: unknown, requestId?: string | undefined);
|
|
15
|
+
static from(status: number, body: unknown, requestId?: string): PimiaApiError;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 401: el access token no vale (caducado, revocado o el usuario retiró el
|
|
19
|
+
* acceso desde Ajustes → Apps conectadas). El cliente intenta refrescar una
|
|
20
|
+
* vez por su cuenta; si vuelve a salir, hay que re-autorizar al usuario.
|
|
21
|
+
*/
|
|
22
|
+
export declare class UnauthorizedError extends PimiaApiError {
|
|
23
|
+
}
|
|
24
|
+
export declare class ForbiddenError extends PimiaApiError {
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 403 del api-guard: al token le falta un scope. `scope` viene parseado del
|
|
28
|
+
* mensaje («Token lacks the invoices:write scope») para que el partner sepa
|
|
29
|
+
* exactamente qué pedir en el próximo authorize.
|
|
30
|
+
*/
|
|
31
|
+
export declare class MissingScopeError extends ForbiddenError {
|
|
32
|
+
readonly scope: string;
|
|
33
|
+
constructor(scope: string, status: number, message: string, body: unknown, requestId?: string);
|
|
34
|
+
}
|
|
35
|
+
export declare class NotFoundError extends PimiaApiError {
|
|
36
|
+
}
|
|
37
|
+
/** 422: validación de negocio. `errors` es el mapa campo → mensajes. */
|
|
38
|
+
export declare class ValidationError extends PimiaApiError {
|
|
39
|
+
get errors(): Record<string, string[]>;
|
|
40
|
+
}
|
|
41
|
+
/** 429: pasado el rate limit. `retryAfter` en segundos si la API lo dijo. */
|
|
42
|
+
export declare class RateLimitError extends PimiaApiError {
|
|
43
|
+
readonly retryAfter: number | undefined;
|
|
44
|
+
constructor(retryAfter: number | undefined, status: number, message: string, body: unknown, requestId?: string);
|
|
45
|
+
}
|
|
46
|
+
/** Error del flujo OAuth (token endpoint, revocación). */
|
|
47
|
+
export declare class OAuthError extends PimiaError {
|
|
48
|
+
readonly error: string;
|
|
49
|
+
readonly description?: string | undefined;
|
|
50
|
+
constructor(error: string, description?: string | undefined);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* No hay tokens con los que trabajar (o se perdieron). Distinto de un 401:
|
|
54
|
+
* aquí ni se intentó la llamada.
|
|
55
|
+
*/
|
|
56
|
+
export declare class NotAuthenticatedError extends PimiaError {
|
|
57
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errores del SDK. La API de Pimia tiene un contrato de errores estable
|
|
3
|
+
* (docs/guia-integradores.md §7) y merece tipos propios: un 403 por scope no
|
|
4
|
+
* se arregla reintentando, un 422 tampoco, y un 429 sí.
|
|
5
|
+
*/
|
|
6
|
+
export class PimiaError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = new.target.name;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Respuesta HTTP de error de la API. */
|
|
13
|
+
export class PimiaApiError extends PimiaError {
|
|
14
|
+
status;
|
|
15
|
+
body;
|
|
16
|
+
requestId;
|
|
17
|
+
constructor(status, message, body, requestId) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.body = body;
|
|
21
|
+
this.requestId = requestId;
|
|
22
|
+
}
|
|
23
|
+
static from(status, body, requestId) {
|
|
24
|
+
const message = messageFrom(body) ?? `HTTP ${status}`;
|
|
25
|
+
if (status === 401)
|
|
26
|
+
return new UnauthorizedError(status, message, body, requestId);
|
|
27
|
+
if (status === 403) {
|
|
28
|
+
const scope = scopeFrom(message);
|
|
29
|
+
if (scope)
|
|
30
|
+
return new MissingScopeError(scope, status, message, body, requestId);
|
|
31
|
+
return new ForbiddenError(status, message, body, requestId);
|
|
32
|
+
}
|
|
33
|
+
if (status === 422)
|
|
34
|
+
return new ValidationError(status, message, body, requestId);
|
|
35
|
+
if (status === 404)
|
|
36
|
+
return new NotFoundError(status, message, body, requestId);
|
|
37
|
+
return new PimiaApiError(status, message, body, requestId);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 401: el access token no vale (caducado, revocado o el usuario retiró el
|
|
42
|
+
* acceso desde Ajustes → Apps conectadas). El cliente intenta refrescar una
|
|
43
|
+
* vez por su cuenta; si vuelve a salir, hay que re-autorizar al usuario.
|
|
44
|
+
*/
|
|
45
|
+
export class UnauthorizedError extends PimiaApiError {
|
|
46
|
+
}
|
|
47
|
+
export class ForbiddenError extends PimiaApiError {
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 403 del api-guard: al token le falta un scope. `scope` viene parseado del
|
|
51
|
+
* mensaje («Token lacks the invoices:write scope») para que el partner sepa
|
|
52
|
+
* exactamente qué pedir en el próximo authorize.
|
|
53
|
+
*/
|
|
54
|
+
export class MissingScopeError extends ForbiddenError {
|
|
55
|
+
scope;
|
|
56
|
+
constructor(scope, status, message, body, requestId) {
|
|
57
|
+
super(status, message, body, requestId);
|
|
58
|
+
this.scope = scope;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export class NotFoundError extends PimiaApiError {
|
|
62
|
+
}
|
|
63
|
+
/** 422: validación de negocio. `errors` es el mapa campo → mensajes. */
|
|
64
|
+
export class ValidationError extends PimiaApiError {
|
|
65
|
+
get errors() {
|
|
66
|
+
const body = this.body;
|
|
67
|
+
return body?.errors ?? {};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** 429: pasado el rate limit. `retryAfter` en segundos si la API lo dijo. */
|
|
71
|
+
export class RateLimitError extends PimiaApiError {
|
|
72
|
+
retryAfter;
|
|
73
|
+
constructor(retryAfter, status, message, body, requestId) {
|
|
74
|
+
super(status, message, body, requestId);
|
|
75
|
+
this.retryAfter = retryAfter;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Error del flujo OAuth (token endpoint, revocación). */
|
|
79
|
+
export class OAuthError extends PimiaError {
|
|
80
|
+
error;
|
|
81
|
+
description;
|
|
82
|
+
constructor(error, description) {
|
|
83
|
+
super(description ? `${error}: ${description}` : error);
|
|
84
|
+
this.error = error;
|
|
85
|
+
this.description = description;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* No hay tokens con los que trabajar (o se perdieron). Distinto de un 401:
|
|
90
|
+
* aquí ni se intentó la llamada.
|
|
91
|
+
*/
|
|
92
|
+
export class NotAuthenticatedError extends PimiaError {
|
|
93
|
+
}
|
|
94
|
+
function messageFrom(body) {
|
|
95
|
+
if (typeof body === 'string' && body !== '')
|
|
96
|
+
return body;
|
|
97
|
+
if (body && typeof body === 'object') {
|
|
98
|
+
const record = body;
|
|
99
|
+
for (const key of ['message', 'error_description', 'error']) {
|
|
100
|
+
if (typeof record[key] === 'string' && record[key] !== '')
|
|
101
|
+
return record[key];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
/** «Token lacks the invoices:write scope» → `invoices:write`. */
|
|
107
|
+
function scopeFrom(message) {
|
|
108
|
+
return /Token lacks the (\S+) scope/.exec(message)?.[1];
|
|
109
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pimia/sdk — cliente TypeScript de la API de Pimia para apps de partner.
|
|
3
|
+
*
|
|
4
|
+
* Un tenant = una base URL = un token (modelo un-token-por-tienda). Empieza
|
|
5
|
+
* por README.md; el contrato completo de endpoints está en el OpenAPI del que
|
|
6
|
+
* salen los tipos de `./api`.
|
|
7
|
+
*/
|
|
8
|
+
export { PimiaClient } from './client.js';
|
|
9
|
+
export type { PimiaClientOptions, RateLimit, RequestOptions } from './client.js';
|
|
10
|
+
export { OAuth, createPkceChallenge, createState } from './oauth.js';
|
|
11
|
+
export type { AuthorizationServerMetadata, AuthorizeUrlOptions, OAuthConfig, PkceChallenge, } from './oauth.js';
|
|
12
|
+
export { MemoryTokenStore, isExpired, tokenSetFromResponse } from './tokens.js';
|
|
13
|
+
export type { TokenSet, TokenStore } from './tokens.js';
|
|
14
|
+
export { ForbiddenError, MissingScopeError, NotAuthenticatedError, NotFoundError, OAuthError, PimiaApiError, PimiaError, RateLimitError, UnauthorizedError, ValidationError, } from './errors.js';
|
|
15
|
+
/** Scopes granulares del catálogo de Pimia (paso 4). Pide siempre lo mínimo. */
|
|
16
|
+
export declare const SCOPES: {
|
|
17
|
+
readonly invoicesRead: "invoices:read";
|
|
18
|
+
readonly invoicesWrite: "invoices:write";
|
|
19
|
+
readonly estimatesRead: "estimates:read";
|
|
20
|
+
readonly estimatesWrite: "estimates:write";
|
|
21
|
+
readonly customersRead: "customers:read";
|
|
22
|
+
readonly customersWrite: "customers:write";
|
|
23
|
+
readonly expensesRead: "expenses:read";
|
|
24
|
+
readonly expensesWrite: "expenses:write";
|
|
25
|
+
readonly paymentsRead: "payments:read";
|
|
26
|
+
readonly paymentsWrite: "payments:write";
|
|
27
|
+
readonly itemsRead: "items:read";
|
|
28
|
+
readonly itemsWrite: "items:write";
|
|
29
|
+
readonly bankingRead: "banking:read";
|
|
30
|
+
readonly bankingWrite: "banking:write";
|
|
31
|
+
readonly crmRead: "crm:read";
|
|
32
|
+
readonly crmWrite: "crm:write";
|
|
33
|
+
readonly agendaRead: "agenda:read";
|
|
34
|
+
readonly agendaWrite: "agenda:write";
|
|
35
|
+
readonly reportsRead: "reports:read";
|
|
36
|
+
};
|
|
37
|
+
export type Scope = (typeof SCOPES)[keyof typeof SCOPES];
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pimia/sdk — cliente TypeScript de la API de Pimia para apps de partner.
|
|
3
|
+
*
|
|
4
|
+
* Un tenant = una base URL = un token (modelo un-token-por-tienda). Empieza
|
|
5
|
+
* por README.md; el contrato completo de endpoints está en el OpenAPI del que
|
|
6
|
+
* salen los tipos de `./api`.
|
|
7
|
+
*/
|
|
8
|
+
export { PimiaClient } from './client.js';
|
|
9
|
+
export { OAuth, createPkceChallenge, createState } from './oauth.js';
|
|
10
|
+
export { MemoryTokenStore, isExpired, tokenSetFromResponse } from './tokens.js';
|
|
11
|
+
export { ForbiddenError, MissingScopeError, NotAuthenticatedError, NotFoundError, OAuthError, PimiaApiError, PimiaError, RateLimitError, UnauthorizedError, ValidationError, } from './errors.js';
|
|
12
|
+
/** Scopes granulares del catálogo de Pimia (paso 4). Pide siempre lo mínimo. */
|
|
13
|
+
export const SCOPES = {
|
|
14
|
+
invoicesRead: 'invoices:read',
|
|
15
|
+
invoicesWrite: 'invoices:write',
|
|
16
|
+
estimatesRead: 'estimates:read',
|
|
17
|
+
estimatesWrite: 'estimates:write',
|
|
18
|
+
customersRead: 'customers:read',
|
|
19
|
+
customersWrite: 'customers:write',
|
|
20
|
+
expensesRead: 'expenses:read',
|
|
21
|
+
expensesWrite: 'expenses:write',
|
|
22
|
+
paymentsRead: 'payments:read',
|
|
23
|
+
paymentsWrite: 'payments:write',
|
|
24
|
+
itemsRead: 'items:read',
|
|
25
|
+
itemsWrite: 'items:write',
|
|
26
|
+
bankingRead: 'banking:read',
|
|
27
|
+
bankingWrite: 'banking:write',
|
|
28
|
+
crmRead: 'crm:read',
|
|
29
|
+
crmWrite: 'crm:write',
|
|
30
|
+
agendaRead: 'agenda:read',
|
|
31
|
+
agendaWrite: 'agenda:write',
|
|
32
|
+
reportsRead: 'reports:read',
|
|
33
|
+
};
|
package/dist/oauth.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ceremonia OAuth 2.0 contra el Authorization Server de Pimia.
|
|
3
|
+
*
|
|
4
|
+
* Un tenant = un servidor de autorización (`https://{tenant}.pimia.es`), y un
|
|
5
|
+
* token vale solo para ese tenant (modelo un-token-por-tienda). Todo lo que
|
|
6
|
+
* hay aquí es de la parte «servidor» de tu app salvo `createPkceChallenge` y
|
|
7
|
+
* `buildAuthorizeUrl`, que también valen en el navegador.
|
|
8
|
+
*/
|
|
9
|
+
import { type TokenSet } from './tokens.js';
|
|
10
|
+
export interface OAuthConfig {
|
|
11
|
+
/** Base del tenant, con o sin barra final: `https://acme.pimia.es`. */
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
clientId: string;
|
|
14
|
+
/** Solo clients confidenciales (app server-side). Nunca en el navegador. */
|
|
15
|
+
clientSecret?: string;
|
|
16
|
+
redirectUri: string;
|
|
17
|
+
fetch?: typeof globalThis.fetch;
|
|
18
|
+
}
|
|
19
|
+
export interface PkceChallenge {
|
|
20
|
+
verifier: string;
|
|
21
|
+
challenge: string;
|
|
22
|
+
method: 'S256';
|
|
23
|
+
}
|
|
24
|
+
export interface AuthorizeUrlOptions {
|
|
25
|
+
/** Scopes granulares: `['invoices:read', 'customers:read']`. Pide lo mínimo. */
|
|
26
|
+
scopes: string[];
|
|
27
|
+
state: string;
|
|
28
|
+
pkce: PkceChallenge;
|
|
29
|
+
}
|
|
30
|
+
/** Metadata del AS (RFC 8414). */
|
|
31
|
+
export interface AuthorizationServerMetadata {
|
|
32
|
+
issuer: string;
|
|
33
|
+
authorization_endpoint: string;
|
|
34
|
+
token_endpoint: string;
|
|
35
|
+
registration_endpoint?: string;
|
|
36
|
+
revocation_endpoint?: string;
|
|
37
|
+
scopes_supported?: string[];
|
|
38
|
+
grant_types_supported?: string[];
|
|
39
|
+
}
|
|
40
|
+
export declare class OAuth {
|
|
41
|
+
private readonly config;
|
|
42
|
+
private readonly baseUrl;
|
|
43
|
+
private readonly doFetch;
|
|
44
|
+
constructor(config: OAuthConfig);
|
|
45
|
+
/** `GET /.well-known/oauth-authorization-server` — útil para no cablear rutas. */
|
|
46
|
+
metadata(): Promise<AuthorizationServerMetadata>;
|
|
47
|
+
/**
|
|
48
|
+
* URL a la que mandas al usuario. Verás la pantalla de consentimiento de
|
|
49
|
+
* Pimia con los permisos de los scopes que pidas.
|
|
50
|
+
*/
|
|
51
|
+
buildAuthorizeUrl({ scopes, state, pkce }: AuthorizeUrlOptions): string;
|
|
52
|
+
/**
|
|
53
|
+
* Canje del `code` del callback. El código dura 10 minutos y un solo uso.
|
|
54
|
+
*
|
|
55
|
+
* Acepta el challenge completo o solo `{ verifier }`: en un flujo real el
|
|
56
|
+
* verifier se recupera de la sesión del usuario y el challenge ya no hace
|
|
57
|
+
* falta (lo tiene el servidor, que lo comparará con el hash del verifier).
|
|
58
|
+
*/
|
|
59
|
+
exchangeCode(code: string, pkce: Pick<PkceChallenge, 'verifier'>): Promise<TokenSet>;
|
|
60
|
+
/**
|
|
61
|
+
* Refresco CON ROTACIÓN: el refresh que pasas queda invalidado y el TokenSet
|
|
62
|
+
* devuelto trae uno nuevo. Persístelo antes de volver a llamar a la API (ver
|
|
63
|
+
* tokens.ts) — reusar el viejo revoca el grant entero.
|
|
64
|
+
*/
|
|
65
|
+
refresh(refreshToken: string): Promise<TokenSet>;
|
|
66
|
+
/**
|
|
67
|
+
* Revocación RFC 7009. Con un refresh token cae el grant ENTERO (todos los
|
|
68
|
+
* access tokens de tu app para ese usuario); con un access token, solo ese.
|
|
69
|
+
* Llámalo cuando el usuario desconecte tu app: es la cortesía mínima.
|
|
70
|
+
*/
|
|
71
|
+
revoke(token: string): Promise<void>;
|
|
72
|
+
private tokenRequest;
|
|
73
|
+
private formHeaders;
|
|
74
|
+
private formBody;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* PKCE S256. Obligatorio para clients públicos y recomendable siempre: liga el
|
|
78
|
+
* código de autorización a quien lo pidió. Guarda el `verifier` en la sesión
|
|
79
|
+
* del usuario hasta que vuelva del callback.
|
|
80
|
+
*/
|
|
81
|
+
export declare function createPkceChallenge(): Promise<PkceChallenge>;
|
|
82
|
+
/** `state` anti-CSRF. Compáralo al volver del callback. */
|
|
83
|
+
export declare function createState(): string;
|