@pimia/sdk 0.2.0 → 0.3.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.
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Webhooks entrantes: verificación de la firma PIMIA-WEBHOOK-v1 y tipos de los
3
+ * payloads del catálogo.
4
+ *
5
+ * Existe porque sin esto cada integrador reescribe el mismo HMAC a mano —y con
6
+ * él las mismas tres trampas—: firmar el JSON reserializado en vez de los bytes
7
+ * recibidos, comparar la firma con `===`, y olvidar la ventana anti-replay.
8
+ *
9
+ * ```ts
10
+ * // Express: OJO, `express.raw()`, no `express.json()`.
11
+ * app.post('/pimia', express.raw({ type: 'application/json' }), async (req, res) => {
12
+ * let hook
13
+ * try {
14
+ * hook = await verifyWebhook({
15
+ * secret: process.env.PIMIA_WEBHOOK_SECRET,
16
+ * headers: req.headers,
17
+ * body: req.body,
18
+ * })
19
+ * } catch (error) {
20
+ * return res.status(400).send((error as WebhookVerificationError).reason)
21
+ * }
22
+ *
23
+ * // Idempotencia: la MISMA entrega reintentada llega con el mismo
24
+ * // `delivery`. Procesa cada uno una sola vez y responde 2xx a los repes.
25
+ * if (await yaProcesado(hook.delivery)) return res.sendStatus(200)
26
+ *
27
+ * if (hook.known) {
28
+ * switch (hook.event) {
29
+ * case 'estimate.accepted':
30
+ * await facturar(hook.payload.id) // payload tipado, sin castings
31
+ * break
32
+ * case 'invoice.paid':
33
+ * await cobrar(hook.payload.id)
34
+ * break
35
+ * }
36
+ * }
37
+ *
38
+ * res.sendStatus(200) // responde rápido: el trabajo pesado, a una cola
39
+ * })
40
+ * ```
41
+ */
42
+ import { PimiaError } from './errors.js';
43
+ /** Versión del canónico firmado. Primera línea de lo que se pasa por el HMAC. */
44
+ export declare const WEBHOOK_SIGNATURE_VERSION = "PIMIA-WEBHOOK-v1";
45
+ /** Cabeceras que Pimia manda en cada entrega (en minúsculas, se leen sin distinguir mayúsculas). */
46
+ export declare const WEBHOOK_HEADERS: {
47
+ readonly signature: "x-pimia-signature";
48
+ readonly timestamp: "x-pimia-timestamp";
49
+ readonly event: "x-pimia-event";
50
+ readonly delivery: "x-pimia-delivery";
51
+ };
52
+ /** Ventana anti-replay por defecto, en segundos. */
53
+ export declare const WEBHOOK_DEFAULT_TOLERANCE_SECONDS = 300;
54
+ /**
55
+ * Catálogo de eventos v1 (`config/webhooks.php` del core).
56
+ *
57
+ * Suscribirse a uno que este SDK todavía no conozca no rompe nada: la firma se
58
+ * verifica igual y la entrega vuelve con `known: false`.
59
+ */
60
+ export declare const WEBHOOK_EVENTS: readonly ["approval.decided", "invoice.received", "app.revoked", "customer.created", "customer.updated", "invoice.created", "estimate.accepted", "invoice.paid"];
61
+ export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number];
62
+ export declare function isWebhookEvent(value: string): value is WebhookEvent;
63
+ /**
64
+ * Marca de tiempo ISO-8601 **con offset** (`2026-08-10T12:34:56+02:00`), nunca
65
+ * `Z` puro: el core las emite con `toIso8601String()`.
66
+ */
67
+ export type IsoDateTime = string;
68
+ /** Une un enum abierto: autocompleta los valores conocidos sin cerrar el tipo. */
69
+ type OpenEnum<T extends string> = T | (string & {});
70
+ /**
71
+ * Una decisión de aprobación delegada se resolvió.
72
+ *
73
+ * Es de plano tenant (no de company) y solo llega al `client_id` dueño de la
74
+ * propuesta.
75
+ */
76
+ export interface ApprovalDecidedPayload {
77
+ plane: OpenEnum<'pyme' | 'gestoria' | 'integrator'>;
78
+ /** La referencia con la que propusiste la tarea: tu asidero de correlación. */
79
+ delegation_ref: string;
80
+ outcome: OpenEnum<'pass' | 'edit' | 'reject'>;
81
+ task_type: string | null;
82
+ autonomous: boolean;
83
+ /** Sello de verificabilidad. `null` si el plano no lo deriva. */
84
+ sello: OpenEnum<'pass' | 'fail' | 'n/a'> | null;
85
+ verification_level: number | null;
86
+ /** Tenant key (la que conoces por tu grant), no el slug interno. */
87
+ tenant: string | null;
88
+ verified_at: IsoDateTime | null;
89
+ }
90
+ /**
91
+ * Alta de una factura recibida (libro de recibidas).
92
+ *
93
+ * Los tipos son deliberadamente anchos: a diferencia del resto del catálogo,
94
+ * este payload NO castea `id`, `sequence_number` ni `currency_id` en origen
95
+ * (`app/Models/ReceivedInvoice.php`), así que pueden llegar como número o como
96
+ * cadena según el driver. Normaliza con `Number(...)` antes de comparar.
97
+ */
98
+ export interface InvoiceReceivedPayload {
99
+ id: number | string;
100
+ number: string | null;
101
+ sequence_number: number | string | null;
102
+ /** Siempre presente; sus dos claves pueden ser `null` si no hay proveedor. */
103
+ supplier: {
104
+ id: number | string | null;
105
+ name: string | null;
106
+ };
107
+ /** En céntimos. */
108
+ total: number;
109
+ currency_id: number | string | null;
110
+ created_at: IsoDateTime | null;
111
+ }
112
+ /**
113
+ * Un grant OAuth quedó revocado: deja de usar sus tokens y vuelve a pedir
114
+ * autorización. `reason: 'reuse'` significa que alguien canjeó un refresh ya
115
+ * rotado — casi siempre, dos procesos refrescando a la vez sin store compartido.
116
+ */
117
+ export interface AppRevokedPayload {
118
+ client_id: string;
119
+ /** `null` = plano central. */
120
+ tenant_id: string | null;
121
+ user_id: number | string;
122
+ reason: OpenEnum<'revoked' | 'reuse'>;
123
+ revoked_tokens: number;
124
+ }
125
+ /** Alta o edición de cliente. Sin PII (ni email ni NIF) por decisión del core. */
126
+ export interface CustomerPayload {
127
+ id: number;
128
+ name: string;
129
+ company_id: number;
130
+ created_at: IsoDateTime | null;
131
+ updated_at: IsoDateTime | null;
132
+ }
133
+ /** Alta de factura. Importes en céntimos. */
134
+ export interface InvoiceCreatedPayload {
135
+ id: number;
136
+ /** `null` mientras es borrador: el número se asigna al publicar. */
137
+ number: string | null;
138
+ sequence_number: number | null;
139
+ status: OpenEnum<'DRAFT' | 'PUBLISHED' | 'SENT' | 'VIEWED' | 'COMPLETED'>;
140
+ paid_status: OpenEnum<'UNPAID' | 'PARTIALLY_PAID' | 'PAID'>;
141
+ is_credit_note: boolean;
142
+ customer_id: number | null;
143
+ company_id: number;
144
+ sub_total: number;
145
+ tax: number;
146
+ total: number;
147
+ due_amount: number;
148
+ currency_id: number | null;
149
+ created_at: IsoDateTime | null;
150
+ }
151
+ /**
152
+ * El CLIENTE FINAL aceptó el presupuesto. Es una transición, no un estado: solo
153
+ * se emite en el cambio a `ACCEPTED`.
154
+ */
155
+ export interface EstimateAcceptedPayload {
156
+ id: number;
157
+ number: string | null;
158
+ sequence_number: number | null;
159
+ status: 'ACCEPTED';
160
+ customer_id: number | null;
161
+ /** Lead de Pimia. `null` si la oportunidad vive en tu sistema, no aquí. */
162
+ lead_id: number | null;
163
+ company_id: number;
164
+ sub_total: number;
165
+ tax: number;
166
+ total: number;
167
+ currency_id: number | null;
168
+ accepted_at: IsoDateTime | null;
169
+ }
170
+ /**
171
+ * La factura quedó cobrada del todo. `PARTIALLY_PAID` no emite: es una
172
+ * transición a `PAID`.
173
+ */
174
+ export interface InvoicePaidPayload {
175
+ id: number;
176
+ number: string | null;
177
+ status: OpenEnum<'DRAFT' | 'PUBLISHED' | 'SENT' | 'VIEWED' | 'COMPLETED'>;
178
+ paid_status: 'PAID';
179
+ is_credit_note: boolean;
180
+ customer_id: number | null;
181
+ company_id: number;
182
+ /** En céntimos. */
183
+ total: number;
184
+ /** 0 en el caso normal; **negativo** si hubo sobrepago. */
185
+ due_amount: number;
186
+ currency_id: number | null;
187
+ paid_at: IsoDateTime | null;
188
+ }
189
+ /** Payload de cada evento del catálogo, por nombre. */
190
+ export interface WebhookPayloads {
191
+ 'approval.decided': ApprovalDecidedPayload;
192
+ 'invoice.received': InvoiceReceivedPayload;
193
+ 'app.revoked': AppRevokedPayload;
194
+ 'customer.created': CustomerPayload;
195
+ 'customer.updated': CustomerPayload;
196
+ 'invoice.created': InvoiceCreatedPayload;
197
+ 'estimate.accepted': EstimateAcceptedPayload;
198
+ 'invoice.paid': InvoicePaidPayload;
199
+ }
200
+ interface WebhookBase {
201
+ /**
202
+ * Id de la entrega (cabecera `X-Pimia-Delivery`). **Tu clave de idempotencia
203
+ * como receptor**: un reintento de Pimia trae el mismo id, así que procesar
204
+ * cada uno una sola vez es todo lo que hace falta para el exactly-once.
205
+ */
206
+ delivery: string;
207
+ /** Epoch en segundos de la cabecera `X-Pimia-Timestamp`, ya validado. */
208
+ timestamp: number;
209
+ }
210
+ /** Entrega de un evento del catálogo que este SDK conoce y tipa. */
211
+ export type KnownWebhook = {
212
+ [K in WebhookEvent]: WebhookBase & {
213
+ known: true;
214
+ event: K;
215
+ payload: WebhookPayloads[K];
216
+ };
217
+ }[WebhookEvent];
218
+ /**
219
+ * Entrega verificada de un evento que este SDK todavía no tipa (catálogo del
220
+ * servidor más nuevo que tu versión del SDK).
221
+ */
222
+ export interface UnknownWebhook extends WebhookBase {
223
+ known: false;
224
+ event: string;
225
+ payload: unknown;
226
+ }
227
+ /**
228
+ * Entrega verificada.
229
+ *
230
+ * El `known` está para que el `switch` sobre `event` narre EXACTO en los ocho
231
+ * eventos tipados sin cerrarle la puerta a uno nuevo: sin él, la rama abierta
232
+ * contaminaría el payload de todas las demás con `unknown`.
233
+ */
234
+ export type PimiaWebhook = KnownWebhook | UnknownWebhook;
235
+ /** Por qué se rechazó una entrega. Legible por máquina, para tus métricas. */
236
+ export type WebhookVerificationReason = 'missing_headers' | 'invalid_timestamp' | 'timestamp_out_of_window' | 'signature_mismatch' | 'invalid_json';
237
+ /**
238
+ * La entrega no es de Pimia, o no es fresca, o no es JSON.
239
+ *
240
+ * Contesta `400` y no proceses nada. Una racha de estos con
241
+ * `signature_mismatch` es la señal de que el secreto de tu endpoint y el del
242
+ * panel han dejado de coincidir.
243
+ */
244
+ export declare class WebhookVerificationError extends PimiaError {
245
+ readonly reason: WebhookVerificationReason;
246
+ constructor(reason: WebhookVerificationReason, message: string);
247
+ }
248
+ /** Lo que se puede leer como cabeceras: `Headers`, `req.headers` de Node o un `Map`. */
249
+ export type WebhookHeadersInput = Headers | Map<string, string | string[] | undefined> | Record<string, string | string[] | undefined>;
250
+ /** El cuerpo TAL Y COMO LLEGÓ. Nunca un objeto ya parseado (ver {@link verifyWebhook}). */
251
+ export type WebhookBodyInput = string | Uint8Array | ArrayBuffer;
252
+ export interface VerifyWebhookOptions {
253
+ /**
254
+ * Secreto del endpoint (el del panel de Pimia). Acepta una lista para poder
255
+ * rotarlo sin ventana de caída: durante el cambio, valen los dos.
256
+ */
257
+ secret: string | readonly string[];
258
+ headers: WebhookHeadersInput;
259
+ /**
260
+ * **Los bytes crudos del cuerpo.** Ver el aviso de {@link verifyWebhook}.
261
+ */
262
+ body: WebhookBodyInput;
263
+ /** Ventana anti-replay en segundos (por defecto 300, la del emisor). */
264
+ toleranceSeconds?: number;
265
+ /** Reloj en segundos epoch. Inyectable solo para tests. */
266
+ now?: () => number;
267
+ }
268
+ /**
269
+ * Verifica una entrega y devuelve el evento tipado.
270
+ *
271
+ * ⚠️ **`body` tienen que ser los BYTES tal y como llegaron.** Pimia firma
272
+ * exactamente el JSON que envía: parsear y volver a serializar produce un
273
+ * objeto equivalente pero otros bytes, y la firma deja de cuadrar sin que se
274
+ * vea por qué. En Express eso significa `express.raw({ type: 'application/json' })`;
275
+ * con `fetch`, `await request.text()` **antes** de cualquier `.json()`.
276
+ *
277
+ * Qué comprueba, en este orden: que estén las cuatro cabeceras, que el
278
+ * timestamp sea un número dentro de la ventana anti-replay, que el HMAC-SHA256
279
+ * del canónico coincida (comparación en tiempo constante) y que el cuerpo sea
280
+ * JSON. Cualquier fallo lanza {@link WebhookVerificationError} con su `reason`.
281
+ *
282
+ * Lo que NO hace, porque es tuyo: deduplicar por `delivery`. Pimia reintenta
283
+ * hasta cinco veces con backoff, así que una entrega puede llegarte más de una
284
+ * vez con la misma firma válida.
285
+ */
286
+ export declare function verifyWebhook(options: VerifyWebhookOptions): Promise<PimiaWebhook>;
287
+ export interface SignWebhookOptions {
288
+ secret: string;
289
+ event: string;
290
+ /** Id de la entrega: el valor de `X-Pimia-Delivery`. */
291
+ deliveryId: number | string;
292
+ body: WebhookBodyInput;
293
+ /** Epoch en segundos. Por defecto, ahora. */
294
+ timestamp?: number;
295
+ }
296
+ /**
297
+ * Firma un cuerpo como lo haría Pimia y devuelve sus cuatro cabeceras.
298
+ *
299
+ * Está aquí **para que puedas testear tu receptor** sin reimplementar el HMAC
300
+ * —que es justo lo que este módulo viene a evitar—: monta el cuerpo que
301
+ * esperas, fírmalo y mándaselo a tu handler. En producción no lo necesitas:
302
+ * quien firma es Pimia.
303
+ */
304
+ export declare function signWebhook(options: SignWebhookOptions): Promise<Record<string, string>>;
305
+ export {};
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Webhooks entrantes: verificación de la firma PIMIA-WEBHOOK-v1 y tipos de los
3
+ * payloads del catálogo.
4
+ *
5
+ * Existe porque sin esto cada integrador reescribe el mismo HMAC a mano —y con
6
+ * él las mismas tres trampas—: firmar el JSON reserializado en vez de los bytes
7
+ * recibidos, comparar la firma con `===`, y olvidar la ventana anti-replay.
8
+ *
9
+ * ```ts
10
+ * // Express: OJO, `express.raw()`, no `express.json()`.
11
+ * app.post('/pimia', express.raw({ type: 'application/json' }), async (req, res) => {
12
+ * let hook
13
+ * try {
14
+ * hook = await verifyWebhook({
15
+ * secret: process.env.PIMIA_WEBHOOK_SECRET,
16
+ * headers: req.headers,
17
+ * body: req.body,
18
+ * })
19
+ * } catch (error) {
20
+ * return res.status(400).send((error as WebhookVerificationError).reason)
21
+ * }
22
+ *
23
+ * // Idempotencia: la MISMA entrega reintentada llega con el mismo
24
+ * // `delivery`. Procesa cada uno una sola vez y responde 2xx a los repes.
25
+ * if (await yaProcesado(hook.delivery)) return res.sendStatus(200)
26
+ *
27
+ * if (hook.known) {
28
+ * switch (hook.event) {
29
+ * case 'estimate.accepted':
30
+ * await facturar(hook.payload.id) // payload tipado, sin castings
31
+ * break
32
+ * case 'invoice.paid':
33
+ * await cobrar(hook.payload.id)
34
+ * break
35
+ * }
36
+ * }
37
+ *
38
+ * res.sendStatus(200) // responde rápido: el trabajo pesado, a una cola
39
+ * })
40
+ * ```
41
+ */
42
+ import { PimiaError } from './errors.js';
43
+ /** Versión del canónico firmado. Primera línea de lo que se pasa por el HMAC. */
44
+ export const WEBHOOK_SIGNATURE_VERSION = 'PIMIA-WEBHOOK-v1';
45
+ /** Cabeceras que Pimia manda en cada entrega (en minúsculas, se leen sin distinguir mayúsculas). */
46
+ export const WEBHOOK_HEADERS = {
47
+ signature: 'x-pimia-signature',
48
+ timestamp: 'x-pimia-timestamp',
49
+ event: 'x-pimia-event',
50
+ delivery: 'x-pimia-delivery',
51
+ };
52
+ /** Ventana anti-replay por defecto, en segundos. */
53
+ export const WEBHOOK_DEFAULT_TOLERANCE_SECONDS = 300;
54
+ /**
55
+ * Catálogo de eventos v1 (`config/webhooks.php` del core).
56
+ *
57
+ * Suscribirse a uno que este SDK todavía no conozca no rompe nada: la firma se
58
+ * verifica igual y la entrega vuelve con `known: false`.
59
+ */
60
+ export const WEBHOOK_EVENTS = [
61
+ 'approval.decided',
62
+ 'invoice.received',
63
+ 'app.revoked',
64
+ 'customer.created',
65
+ 'customer.updated',
66
+ 'invoice.created',
67
+ 'estimate.accepted',
68
+ 'invoice.paid',
69
+ ];
70
+ export function isWebhookEvent(value) {
71
+ return WEBHOOK_EVENTS.includes(value);
72
+ }
73
+ /**
74
+ * La entrega no es de Pimia, o no es fresca, o no es JSON.
75
+ *
76
+ * Contesta `400` y no proceses nada. Una racha de estos con
77
+ * `signature_mismatch` es la señal de que el secreto de tu endpoint y el del
78
+ * panel han dejado de coincidir.
79
+ */
80
+ export class WebhookVerificationError extends PimiaError {
81
+ reason;
82
+ constructor(reason, message) {
83
+ super(message);
84
+ this.reason = reason;
85
+ }
86
+ }
87
+ /**
88
+ * Verifica una entrega y devuelve el evento tipado.
89
+ *
90
+ * ⚠️ **`body` tienen que ser los BYTES tal y como llegaron.** Pimia firma
91
+ * exactamente el JSON que envía: parsear y volver a serializar produce un
92
+ * objeto equivalente pero otros bytes, y la firma deja de cuadrar sin que se
93
+ * vea por qué. En Express eso significa `express.raw({ type: 'application/json' })`;
94
+ * con `fetch`, `await request.text()` **antes** de cualquier `.json()`.
95
+ *
96
+ * Qué comprueba, en este orden: que estén las cuatro cabeceras, que el
97
+ * timestamp sea un número dentro de la ventana anti-replay, que el HMAC-SHA256
98
+ * del canónico coincida (comparación en tiempo constante) y que el cuerpo sea
99
+ * JSON. Cualquier fallo lanza {@link WebhookVerificationError} con su `reason`.
100
+ *
101
+ * Lo que NO hace, porque es tuyo: deduplicar por `delivery`. Pimia reintenta
102
+ * hasta cinco veces con backoff, así que una entrega puede llegarte más de una
103
+ * vez con la misma firma válida.
104
+ */
105
+ export async function verifyWebhook(options) {
106
+ const signature = readHeader(options.headers, WEBHOOK_HEADERS.signature);
107
+ const timestampRaw = readHeader(options.headers, WEBHOOK_HEADERS.timestamp);
108
+ const event = readHeader(options.headers, WEBHOOK_HEADERS.event);
109
+ const delivery = readHeader(options.headers, WEBHOOK_HEADERS.delivery);
110
+ if (!signature || !timestampRaw || !event || !delivery) {
111
+ throw new WebhookVerificationError('missing_headers', 'Faltan cabeceras de firma: se esperan x-pimia-signature, x-pimia-timestamp, x-pimia-event y x-pimia-delivery.');
112
+ }
113
+ const timestamp = Number(timestampRaw);
114
+ if (!Number.isFinite(timestamp)) {
115
+ throw new WebhookVerificationError('invalid_timestamp', `La cabecera x-pimia-timestamp no es un número: ${timestampRaw}`);
116
+ }
117
+ const tolerance = options.toleranceSeconds ?? WEBHOOK_DEFAULT_TOLERANCE_SECONDS;
118
+ const now = options.now?.() ?? Math.floor(Date.now() / 1000);
119
+ const age = Math.abs(now - timestamp);
120
+ if (age > tolerance) {
121
+ throw new WebhookVerificationError('timestamp_out_of_window', `Entrega fuera de la ventana anti-replay: ${age}s de desfase, el máximo es ${tolerance}s.`);
122
+ }
123
+ const bodyBytes = toBytes(options.body);
124
+ const canonical = canonicalBytes(timestampRaw, event, delivery, bodyBytes);
125
+ const secrets = typeof options.secret === 'string' ? [options.secret] : options.secret;
126
+ let matches = false;
127
+ for (const secret of secrets) {
128
+ const expected = `sha256=${await hmacSha256Hex(secret, canonical)}`;
129
+ // Sin cortocircuito: se comprueban todos los secretos siempre, para que el
130
+ // tiempo de respuesta no diga cuál de ellos acertó.
131
+ matches = constantTimeEquals(signature, expected) || matches;
132
+ }
133
+ if (!matches) {
134
+ throw new WebhookVerificationError('signature_mismatch', 'La firma no coincide: la entrega no viene de Pimia, o el secreto del endpoint no es el que crees, o el cuerpo se reserializó por el camino.');
135
+ }
136
+ let payload;
137
+ try {
138
+ payload = JSON.parse(new TextDecoder().decode(bodyBytes));
139
+ }
140
+ catch {
141
+ throw new WebhookVerificationError('invalid_json', 'La firma es válida pero el cuerpo no es JSON. No debería pasar: repórtalo.');
142
+ }
143
+ const base = { delivery, timestamp };
144
+ return isWebhookEvent(event)
145
+ ? { ...base, known: true, event, payload }
146
+ : { ...base, known: false, event, payload };
147
+ }
148
+ /**
149
+ * Firma un cuerpo como lo haría Pimia y devuelve sus cuatro cabeceras.
150
+ *
151
+ * Está aquí **para que puedas testear tu receptor** sin reimplementar el HMAC
152
+ * —que es justo lo que este módulo viene a evitar—: monta el cuerpo que
153
+ * esperas, fírmalo y mándaselo a tu handler. En producción no lo necesitas:
154
+ * quien firma es Pimia.
155
+ */
156
+ export async function signWebhook(options) {
157
+ const timestamp = options.timestamp ?? Math.floor(Date.now() / 1000);
158
+ const delivery = String(options.deliveryId);
159
+ const canonical = canonicalBytes(String(timestamp), options.event, delivery, toBytes(options.body));
160
+ return {
161
+ [WEBHOOK_HEADERS.signature]: `sha256=${await hmacSha256Hex(options.secret, canonical)}`,
162
+ [WEBHOOK_HEADERS.timestamp]: String(timestamp),
163
+ [WEBHOOK_HEADERS.event]: options.event,
164
+ [WEBHOOK_HEADERS.delivery]: delivery,
165
+ };
166
+ }
167
+ /**
168
+ * El canónico: versión, timestamp, evento e id de entrega en texto, y el cuerpo
169
+ * pegado como BYTES. Se concatena en binario a propósito —en vez de armar una
170
+ * cadena— para no meter un viaje de ida y vuelta por UTF-8 que pudiera alterar
171
+ * lo que se firma.
172
+ */
173
+ function canonicalBytes(timestamp, event, delivery, body) {
174
+ const prefix = new TextEncoder().encode(`${WEBHOOK_SIGNATURE_VERSION}\n${timestamp}\n${event}\n${delivery}\n`);
175
+ const canonical = new Uint8Array(prefix.length + body.length);
176
+ canonical.set(prefix);
177
+ canonical.set(body, prefix.length);
178
+ return canonical;
179
+ }
180
+ function toBytes(body) {
181
+ if (typeof body === 'string')
182
+ return new TextEncoder().encode(body);
183
+ if (body instanceof Uint8Array)
184
+ return body;
185
+ return new Uint8Array(body);
186
+ }
187
+ /** WebCrypto, no `node:crypto`: el SDK vale en cualquier runtime con `fetch`. */
188
+ async function hmacSha256Hex(secret, message) {
189
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
190
+ const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, message));
191
+ let hex = '';
192
+ for (const byte of signature)
193
+ hex += byte.toString(16).padStart(2, '0');
194
+ return hex;
195
+ }
196
+ /**
197
+ * Comparación en tiempo constante.
198
+ *
199
+ * Con `===`, el tiempo de comparación depende del prefijo que acierta, y eso
200
+ * permite construir una firma válida byte a byte. La longitud sí se compara
201
+ * antes: una firma de otra longitud ya es inválida y su longitud no es secreta.
202
+ */
203
+ function constantTimeEquals(a, b) {
204
+ if (a.length !== b.length)
205
+ return false;
206
+ let diff = 0;
207
+ for (let i = 0; i < a.length; i++)
208
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
209
+ return diff === 0;
210
+ }
211
+ function readHeader(headers, name) {
212
+ const raw = typeof headers.get === 'function'
213
+ ? headers.get(name)
214
+ : headers instanceof Map
215
+ ? (headers.get(name) ?? headers.get(name.toLowerCase()))
216
+ : pickInsensitive(headers, name);
217
+ const value = Array.isArray(raw) ? raw[0] : raw;
218
+ return value === null || value === undefined || value === '' ? undefined : value;
219
+ }
220
+ function pickInsensitive(headers, name) {
221
+ const direct = headers[name];
222
+ if (direct !== undefined)
223
+ return direct;
224
+ const wanted = name.toLowerCase();
225
+ for (const key of Object.keys(headers)) {
226
+ if (key.toLowerCase() === wanted)
227
+ return headers[key];
228
+ }
229
+ return undefined;
230
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pimia/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.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)",