planvortex 0.0.1

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,255 @@
1
+ 'use strict';
2
+
3
+ var crypto = require('crypto');
4
+
5
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
6
+
7
+ var crypto__default = /*#__PURE__*/_interopDefault(crypto);
8
+
9
+ // src/webhooks/index.ts
10
+
11
+ // src/core/errors.ts
12
+ var PLANVORTEX_ERROR_RANGES = [
13
+ { from: 500, to: 541, family: "auth" },
14
+ { from: 601, to: 612, family: "user" },
15
+ { from: 700, to: 715, family: "account" },
16
+ { from: 800, to: 810, family: "file" },
17
+ { from: 900, to: 960, family: "publication" },
18
+ { from: 1e3, to: 1003, family: "general" },
19
+ { from: 1100, to: 1111, family: "organization" },
20
+ { from: 1200, to: 1207, family: "role" },
21
+ { from: 1300, to: 1307, family: "plan_limit" },
22
+ { from: 1400, to: 1408, family: "plan_limit" },
23
+ { from: 1500, to: 1512, family: "messaging" },
24
+ { from: 1600, to: 1601, family: "contact" },
25
+ { from: 1900, to: 1906, family: "payment" },
26
+ { from: 2e3, to: 2099, family: "product" },
27
+ { from: 2100, to: 2199, family: "ai_plan" },
28
+ { from: 2200, to: 2299, family: "integration" }
29
+ ];
30
+ var NO_ERROR_CODE = 0;
31
+ var PlanVortexError = class extends Error {
32
+ /** El `code` del cuerpo, tal cual. {@link NO_ERROR_CODE} si el error no viene del catálogo. */
33
+ code;
34
+ /** La familia del rango: `auth`, `publication`, `plan_limit`... Ver {@link PLANVORTEX_ERROR_RANGES}. */
35
+ family;
36
+ /** El `data` del cuerpo. */
37
+ data;
38
+ /** Status HTTP, o `undefined` si nunca hubo respuesta. */
39
+ status;
40
+ /** `x-request-id`, si el despliegue lo pone. Hoy el servidor no lo emite; un proxy delante sí. */
41
+ requestId;
42
+ /** Segundos que pidió esperar la cabecera `Retry-After`, si llegó. */
43
+ retryAfter;
44
+ constructor(code, message, options = {}) {
45
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
46
+ this.name = new.target.name;
47
+ this.code = code;
48
+ this.family = options.family ?? errorFamilyForCode(code) ?? "unknown";
49
+ this.data = options.data ?? {};
50
+ this.status = options.status;
51
+ this.requestId = options.requestId;
52
+ this.retryAfter = options.retryAfter;
53
+ }
54
+ };
55
+ function errorFamilyForCode(code) {
56
+ return PLANVORTEX_ERROR_RANGES.find((range) => code >= range.from && code <= range.to)?.family;
57
+ }
58
+
59
+ // src/webhooks/index.ts
60
+ var WEBHOOK_SIGNATURE_HEADERS = {
61
+ sha1: "x-hub-signature",
62
+ sha256: "x-hub-signature-256"
63
+ };
64
+ var WEBHOOK_EVENTS = [
65
+ "new_account",
66
+ "change_state_account",
67
+ "messages",
68
+ "messaging_postbacks",
69
+ "messaging_seen",
70
+ "messaging_error",
71
+ "comments",
72
+ "integration_error"
73
+ ];
74
+ var ACCOUNT_STATE_EVENTS = /* @__PURE__ */ new Set(["new_account", "change_state_account"]);
75
+ var MESSAGE_EVENTS = /* @__PURE__ */ new Set([
76
+ "messages",
77
+ "messaging_postbacks",
78
+ "messaging_seen",
79
+ "messaging_error"
80
+ ]);
81
+ function isAccountStateChange(change) {
82
+ return ACCOUNT_STATE_EVENTS.has(change.field);
83
+ }
84
+ function isMessageChange(change) {
85
+ return MESSAGE_EVENTS.has(change.field);
86
+ }
87
+ function isCommentChange(change) {
88
+ return change.field === "comments";
89
+ }
90
+ function isIntegrationErrorChange(change) {
91
+ return change.field === "integration_error";
92
+ }
93
+ var WebhookSignatureError = class extends PlanVortexError {
94
+ constructor(message) {
95
+ super(NO_ERROR_CODE, message, { family: "webhook" });
96
+ }
97
+ };
98
+ var WebhookBodyError = class extends PlanVortexError {
99
+ constructor(message) {
100
+ super(NO_ERROR_CODE, message, { family: "webhook" });
101
+ }
102
+ };
103
+ function verifyWebhookSignature(options) {
104
+ const { payload, signature, secret, algorithm = "sha256" } = options;
105
+ if (!secret) {
106
+ throw new WebhookBodyError("Falta el client_secret con el que verificar la firma.");
107
+ }
108
+ const body = toBuffer(payload);
109
+ const received = normalizeSignature(signature, algorithm);
110
+ if (!received) {
111
+ return false;
112
+ }
113
+ const expected = crypto__default.default.createHmac(algorithm, secret).update(body).digest("hex");
114
+ if (expected.length !== received.length) {
115
+ return false;
116
+ }
117
+ return crypto__default.default.timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(received, "utf8"));
118
+ }
119
+ function normalizeSignature(signature, algorithm) {
120
+ const value = Array.isArray(signature) ? signature[0] : signature;
121
+ if (typeof value !== "string" || !value) {
122
+ return void 0;
123
+ }
124
+ const separator = value.indexOf("=");
125
+ if (separator === -1) {
126
+ return value;
127
+ }
128
+ return value.slice(0, separator) === algorithm ? value.slice(separator + 1) : void 0;
129
+ }
130
+ function toBuffer(payload) {
131
+ if (typeof payload === "string") {
132
+ return Buffer.from(payload, "utf8");
133
+ }
134
+ if (Buffer.isBuffer(payload)) {
135
+ return payload;
136
+ }
137
+ if (payload instanceof Uint8Array) {
138
+ return Buffer.from(payload);
139
+ }
140
+ throw new WebhookBodyError(
141
+ 'El cuerpo del webhook tiene que ser los BYTES que llegaron (Buffer, Uint8Array o string), no el JSON ya parseado: la firma se calcula sobre esos bytes y volver a serializar el objeto los cambia. En Express: express.raw({ type: "application/json" }).'
142
+ );
143
+ }
144
+ function handleWebhookRequest(options) {
145
+ const { body, headers, secret, algorithm } = options;
146
+ const chosen = algorithm ?? (readHeader(headers, WEBHOOK_SIGNATURE_HEADERS.sha256) ? "sha256" : "sha1");
147
+ const signature = readHeader(headers, WEBHOOK_SIGNATURE_HEADERS[chosen]);
148
+ if (!signature) {
149
+ throw new WebhookSignatureError(
150
+ `La entrega no trae la cabecera ${WEBHOOK_SIGNATURE_HEADERS[chosen]}. Si est\xE1s detr\xE1s de un proxy, comprueba que no la est\xE9 quitando.`
151
+ );
152
+ }
153
+ if (!verifyWebhookSignature({ payload: body, signature, secret, algorithm: chosen })) {
154
+ throw new WebhookSignatureError(
155
+ `La firma ${WEBHOOK_SIGNATURE_HEADERS[chosen]} no cuadra con el cuerpo recibido. Las dos causas de siempre: el cuerpo no es el crudo, o el secreto no es el de esta app.`
156
+ );
157
+ }
158
+ return parseWebhookPayload(body);
159
+ }
160
+ function parseWebhookPayload(body) {
161
+ const text = toBuffer(body).toString("utf8");
162
+ let parsed;
163
+ try {
164
+ parsed = JSON.parse(text);
165
+ } catch (cause) {
166
+ throw new WebhookBodyError(`El cuerpo del webhook no es JSON v\xE1lido: ${cause.message}`);
167
+ }
168
+ if (!Array.isArray(parsed)) {
169
+ throw new WebhookBodyError(
170
+ `El cuerpo del webhook es un ARRAY de cambios, y ha llegado ${parsed === null ? "null" : typeof parsed}. Recorre lo que llega.`
171
+ );
172
+ }
173
+ return parsed;
174
+ }
175
+ function readHeader(headers, name) {
176
+ if (headers && typeof headers.get === "function") {
177
+ return headers.get(name) ?? void 0;
178
+ }
179
+ const record = headers;
180
+ const direct = record[name] ?? record[name.toLowerCase()];
181
+ const found = direct ?? Object.entries(record).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
182
+ return Array.isArray(found) ? found[0] : found;
183
+ }
184
+ function planvortexWebhooks(options) {
185
+ const { secret, onChanges, onError, algorithm } = options;
186
+ return (request, response, next) => {
187
+ void (async () => {
188
+ let changes;
189
+ try {
190
+ const body = await resolveRawBody(request);
191
+ changes = handleWebhookRequest({
192
+ body,
193
+ headers: request.headers,
194
+ secret,
195
+ ...algorithm ? { algorithm } : {}
196
+ });
197
+ } catch (error) {
198
+ onError?.(error, request);
199
+ response.statusCode = error instanceof WebhookSignatureError ? 401 : 400;
200
+ response.end();
201
+ return;
202
+ }
203
+ try {
204
+ await onChanges(changes, request);
205
+ } catch (error) {
206
+ onError?.(error, request);
207
+ response.statusCode = 500;
208
+ response.end();
209
+ next(error);
210
+ return;
211
+ }
212
+ response.statusCode = 200;
213
+ response.end();
214
+ })();
215
+ };
216
+ }
217
+ async function resolveRawBody(request) {
218
+ const body = request.body;
219
+ if (typeof body === "string" || Buffer.isBuffer(body) || body instanceof Uint8Array) {
220
+ return body;
221
+ }
222
+ if (body === void 0 || body === null) {
223
+ const streamed = await readStream(request);
224
+ if (streamed) {
225
+ return streamed;
226
+ }
227
+ }
228
+ return toBuffer(body);
229
+ }
230
+ async function readStream(request) {
231
+ const iterable = request;
232
+ if (typeof iterable?.[Symbol.asyncIterator] !== "function") {
233
+ return void 0;
234
+ }
235
+ const chunks = [];
236
+ for await (const chunk of iterable) {
237
+ chunks.push(chunk);
238
+ }
239
+ return Buffer.concat(chunks);
240
+ }
241
+
242
+ exports.WEBHOOK_EVENTS = WEBHOOK_EVENTS;
243
+ exports.WEBHOOK_SIGNATURE_HEADERS = WEBHOOK_SIGNATURE_HEADERS;
244
+ exports.WebhookBodyError = WebhookBodyError;
245
+ exports.WebhookSignatureError = WebhookSignatureError;
246
+ exports.handleWebhookRequest = handleWebhookRequest;
247
+ exports.isAccountStateChange = isAccountStateChange;
248
+ exports.isCommentChange = isCommentChange;
249
+ exports.isIntegrationErrorChange = isIntegrationErrorChange;
250
+ exports.isMessageChange = isMessageChange;
251
+ exports.parseWebhookPayload = parseWebhookPayload;
252
+ exports.planvortexWebhooks = planvortexWebhooks;
253
+ exports.verifyWebhookSignature = verifyWebhookSignature;
254
+ //# sourceMappingURL=index.cjs.map
255
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/errors.ts","../../src/webhooks/index.ts"],"names":["crypto"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * El catálogo de errores de PlanVortex, por rangos, y las clases que salen de él.\n *\n * LA REGLA, y no es negociable: **los errores se clasifican por `body.code`, nunca por el status\n * HTTP.** Todo error de dominio viaja con un 400 — un token caducado, una cuenta desconectada, el\n * cupo del plan agotado y un texto demasiado largo son los cuatro un 400. Sólo el 520 (permisos)\n * sale 401 y un fallo inesperado sale 500. Un `if (response.status === 401) refreshToken()` sería\n * un bug silencioso: los códigos de token, 501 y 522, viajan dentro de un 400.\n *\n * El catálogo del servidor crece cada mes, así que un código fuera de estos rangos NO es un error\n * del cliente: cae en la clase base con su `code` y su `message` intactos. Nunca se traga y nunca\n * se renombra.\n */\n\nexport type PlanVortexErrorRange = {\n /** Primer código del rango, incluido */\n from: number;\n /** Último código del rango, incluido */\n to: number;\n /** Qué familia de problemas es */\n family: string;\n};\n\nexport const PLANVORTEX_ERROR_RANGES: readonly PlanVortexErrorRange[] = [\n { from: 500, to: 541, family: \"auth\" },\n { from: 601, to: 612, family: \"user\" },\n { from: 700, to: 715, family: \"account\" },\n { from: 800, to: 810, family: \"file\" },\n { from: 900, to: 960, family: \"publication\" },\n { from: 1000, to: 1003, family: \"general\" },\n { from: 1100, to: 1111, family: \"organization\" },\n { from: 1200, to: 1207, family: \"role\" },\n { from: 1300, to: 1307, family: \"plan_limit\" },\n { from: 1400, to: 1408, family: \"plan_limit\" },\n { from: 1500, to: 1512, family: \"messaging\" },\n { from: 1600, to: 1601, family: \"contact\" },\n { from: 1900, to: 1906, family: \"payment\" },\n { from: 2000, to: 2099, family: \"product\" },\n { from: 2100, to: 2199, family: \"ai_plan\" },\n { from: 2200, to: 2299, family: \"integration\" },\n] as const;\n\n/**\n * El `code` que lleva un error que **no** trae código del servidor: un fallo de red, un timeout, un\n * 502 de un proxy con cuerpo HTML, o el propio constructor quejándose de la configuración.\n *\n * El servidor no emite nunca el 0, así que sirve de centinela sin pisar el catálogo. Cuál de esos\n * casos es se distingue por `family`: `connection`, `http`, `oauth`, `config` o `webhook`.\n */\nexport const NO_ERROR_CODE = 0;\n\n/**\n * Los dos códigos que significan \"tu token ya no sirve\". Los dos llegan **dentro de un 400**, que es\n * justo por lo que existe esta constante: quien mire el status no los va a encontrar.\n *\n * El 520 (permisos) NO está aquí a propósito: sale 401, pero pedir un token nuevo no lo arregla — a\n * la app le faltan permisos, y con un token recién emitido le seguirán faltando.\n */\nexport const TOKEN_ERROR_CODES: readonly number[] = [501, 522];\n\n/** Opciones de construcción de un error. Todas opcionales: un error siempre se puede construir. */\nexport interface PlanVortexErrorOptions {\n /** El `data` del cuerpo — lo que el servidor adjuntó con `.withData({...})`. `{}` si no vino nada. */\n data?: Record<string, unknown>;\n /** Status HTTP. `undefined` cuando la petición no llegó a tener respuesta. */\n status?: number;\n /** `x-request-id` de la respuesta, si el despliegue lo pone delante. */\n requestId?: string;\n /** Segundos de la cabecera `Retry-After`, cuando la hay. */\n retryAfter?: number;\n /** El error original (un `TypeError` de `fetch`, por ejemplo). */\n cause?: unknown;\n /**\n * Familia, sólo para los errores que NO salen del catálogo: `connection`, `http`, `oauth`,\n * `config` y `webhook`. Los de dominio la deducen de su `code` y no la pasan nunca.\n */\n family?: string;\n}\n\n/**\n * La base de todo lo que lanza esta librería.\n *\n * Un `catch (e) { if (e instanceof PlanVortexError) }` los coge todos: los de dominio, los de red y\n * los de configuración. Para afinar están las subclases y el `code`.\n */\nexport class PlanVortexError extends Error {\n /** El `code` del cuerpo, tal cual. {@link NO_ERROR_CODE} si el error no viene del catálogo. */\n readonly code: number;\n /** La familia del rango: `auth`, `publication`, `plan_limit`... Ver {@link PLANVORTEX_ERROR_RANGES}. */\n readonly family: string;\n /** El `data` del cuerpo. */\n readonly data: Record<string, unknown>;\n /** Status HTTP, o `undefined` si nunca hubo respuesta. */\n readonly status: number | undefined;\n /** `x-request-id`, si el despliegue lo pone. Hoy el servidor no lo emite; un proxy delante sí. */\n readonly requestId: string | undefined;\n /** Segundos que pidió esperar la cabecera `Retry-After`, si llegó. */\n readonly retryAfter: number | undefined;\n\n constructor(code: number, message: string, options: PlanVortexErrorOptions = {}) {\n super(message, options.cause === undefined ? undefined : { cause: options.cause });\n this.name = new.target.name;\n this.code = code;\n this.family = options.family ?? errorFamilyForCode(code) ?? \"unknown\";\n this.data = options.data ?? {};\n this.status = options.status;\n this.requestId = options.requestId;\n this.retryAfter = options.retryAfter;\n }\n}\n\n/** 500-541 — tokens, apps de cliente, permisos. Incluye el 501 y el 522, los de token caducado. */\nexport class AuthError extends PlanVortexError {}\n/** 601-612 — el usuario final. */\nexport class UserError extends PlanVortexError {}\n/** 700-715 — cuentas sociales: desconectada, sin permisos en la red, sin refrescar. */\nexport class AccountError extends PlanVortexError {}\n/** 800-810 — ficheros: formato no admitido, demasiado grande, conversión fallida. */\nexport class FileError extends PlanVortexError {}\n/** 900-960 — publicaciones, incluidos los límites por red (caracteres, imágenes, duración). */\nexport class PublicationError extends PlanVortexError {}\n/** 1100-1111 — organizaciones, y el token temporal atado a una sola de ellas (1101). */\nexport class OrganizationError extends PlanVortexError {}\n/**\n * 1300-1307 y 1400-1408 — el cupo del plan, del cliente o de la organización.\n *\n * Es el error que un integrador **sí** quiere distinguir: no se arregla reintentando, se arregla\n * cambiando de plan. Por eso los dos rangos comparten clase.\n */\nexport class PlanLimitError extends PlanVortexError {}\n/** 1500-1512 — conversaciones, mensajes y plantillas. Exige plan de pago. */\nexport class MessagingError extends PlanVortexError {}\n/** 1600-1601 — contactos. */\nexport class ContactError extends PlanVortexError {}\n/** 2000-2099 — catálogos y productos (sólo Facebook e Instagram). */\nexport class ProductError extends PlanVortexError {}\n/** 2100-2199 — planes de publicaciones generados con IA. */\nexport class AiPlanError extends PlanVortexError {}\n/** 2200-2299 — integraciones: Google Drive, RSS. */\nexport class IntegrationError extends PlanVortexError {}\n\n/**\n * La petición no llegó a tener respuesta: DNS, conexión rechazada, socket cortado o timeout.\n *\n * No lleva código del catálogo porque el servidor nunca llegó a opinar.\n */\nexport class PlanVortexConnectionError extends PlanVortexError {\n /** `true` si lo que se agotó fue nuestro propio timeout, no la red. */\n readonly timeout: boolean;\n\n constructor(message: string, options: PlanVortexErrorOptions & { timeout?: boolean } = {}) {\n super(NO_ERROR_CODE, message, { ...options, family: \"connection\" });\n this.timeout = options.timeout ?? false;\n }\n}\n\n/**\n * `POST /oauth/token` rechazó las credenciales.\n *\n * Es el ÚNICO sitio del API con forma de error distinta: `{error, error_description}` de OAuth2, no\n * el `{code, message, data}` de todo lo demás. Y por eso el `code` es {@link NO_ERROR_CODE}: el\n * servidor tiene códigos para esto (538-541) pero **no los manda en el cuerpo**, así que ponerlos\n * aquí sería inventarse algo que nadie dijo. Lo que sí viaja es `oauthError`.\n */\nexport class PlanVortexAuthenticationError extends PlanVortexError {\n /** `invalid_client`, `invalid_request`, `unsupported_grant_type`, `slow_down` o `server_error`. */\n readonly oauthError: string;\n\n constructor(oauthError: string, description: string, options: PlanVortexErrorOptions = {}) {\n super(NO_ERROR_CODE, description, { ...options, family: \"oauth\" });\n this.oauthError = oauthError;\n }\n}\n\n/**\n * La librería está mal configurada y no ha llegado a salir de casa: sin credenciales, o instanciada\n * en un navegador (§ trampa 9 del roadmap).\n */\nexport class PlanVortexConfigError extends PlanVortexError {\n constructor(message: string) {\n super(NO_ERROR_CODE, message, { family: \"config\" });\n }\n}\n\n/** La familia a la que pertenece un código, o `undefined` si cae fuera del catálogo conocido. */\nexport function errorFamilyForCode(code: number): string | undefined {\n return PLANVORTEX_ERROR_RANGES.find((range) => code >= range.from && code <= range.to)?.family;\n}\n\n/**\n * Familia -> clase. Las que faltan —`general`, `role`, `payment` y cualquier rango nuevo— caen a\n * propósito en la clase base: existen en el servidor pero no son superficie de integración, y darles\n * clase propia sería prometer un `instanceof` que luego habría que mantener.\n */\nconst FAMILY_CLASSES: Record<string, typeof PlanVortexError> = {\n auth: AuthError,\n user: UserError,\n account: AccountError,\n file: FileError,\n publication: PublicationError,\n organization: OrganizationError,\n plan_limit: PlanLimitError,\n messaging: MessagingError,\n contact: ContactError,\n product: ProductError,\n ai_plan: AiPlanError,\n integration: IntegrationError,\n};\n\n/** El cuerpo de error del API: `{code, message, data}`. */\nexport interface ApiErrorBody {\n code: number;\n message?: string;\n data?: Record<string, unknown>;\n}\n\nfunction isApiErrorBody(body: unknown): body is ApiErrorBody {\n return typeof body === \"object\" && body !== null && typeof (body as ApiErrorBody).code === \"number\";\n}\n\n/**\n * Convierte una respuesta de error en la clase que le toca.\n *\n * Un cuerpo sin `code` —un 502 de un proxy, la página HTML de un balanceador— no es un error de\n * dominio: sale como clase base con `family: \"http\"` y el cuerpo entero en `data`, que es además lo\n * que necesita `auth.ts` para reconocer el `{error, error_description}` de OAuth2.\n */\nexport function createErrorFromResponse(input: {\n body: unknown;\n status: number;\n requestId?: string | undefined;\n retryAfter?: number | undefined;\n}): PlanVortexError {\n const options: PlanVortexErrorOptions = {\n status: input.status,\n ...(input.requestId === undefined ? {} : { requestId: input.requestId }),\n ...(input.retryAfter === undefined ? {} : { retryAfter: input.retryAfter }),\n };\n\n if (isApiErrorBody(input.body)) {\n const family = errorFamilyForCode(input.body.code);\n const ErrorClass = (family === undefined ? undefined : FAMILY_CLASSES[family]) ?? PlanVortexError;\n return new ErrorClass(input.body.code, input.body.message ?? \"PlanVortex error\", {\n ...options,\n data: input.body.data ?? {},\n });\n }\n\n return new PlanVortexError(NO_ERROR_CODE, `HTTP ${input.status}`, {\n ...options,\n family: \"http\",\n data:\n typeof input.body === \"object\" && input.body !== null\n ? (input.body as Record<string, unknown>)\n : { body: input.body },\n });\n}\n\n/** ¿Es un error de esta librería? Útil en un `catch` donde no apetece importar la clase. */\nexport function isPlanVortexError(error: unknown): error is PlanVortexError {\n return error instanceof PlanVortexError;\n}\n\n/**\n * ¿Dice este error que el token ya no sirve? (los códigos 501 y 522, los dos dentro de un 400).\n *\n * Es lo que dispara el único reintento con token nuevo del cliente.\n */\nexport function isTokenError(error: unknown): boolean {\n return isPlanVortexError(error) && TOKEN_ERROR_CODES.includes(error.code);\n}\n","/**\n * `planvortex/webhooks` — los eventos que PlanVortex manda a tu app, y cómo comprobar que son suyos.\n *\n * Va en su propio punto de entrada porque quien recibe webhooks casi nunca es el mismo proceso que\n * publica: un endpoint de Express no tiene por qué cargar el cliente entero.\n *\n * DOS COSAS QUE TUMBAN A TODO EL MUNDO, y por eso están escritas antes que el código:\n *\n * 1. **El cuerpo es un ARRAY de cambios**, no un objeto. Recorre lo que llega.\n * 2. **La firma se calcula sobre el cuerpo CRUDO.** Si tu framework ya parseó el JSON y lo vuelves\n * a serializar, los bytes no son los mismos y la firma no cuadra nunca. En Express hace falta\n * `express.raw({ type: \"application/json\" })` delante — o dejar que\n * {@link planvortexWebhooks} lea el flujo él mismo, que es lo que hace si no encuentra cuerpo.\n *\n * Y una tercera que no tumba, pero cuesta dinero: **PlanVortex no reintenta una entrega fallida.**\n * Un 500 tuyo pierde el evento. Si tu trabajo es lento, encólalo y responde.\n */\nimport crypto from \"node:crypto\";\n\nimport { NO_ERROR_CODE, PlanVortexError } from \"../core/errors.js\";\nimport type { Comment, Message, SocialNetwork } from \"../types.js\";\n\n// -------------------------------------------------------------------------------------------\n// El contrato: cabeceras y eventos\n// -------------------------------------------------------------------------------------------\n\n/**\n * Las dos cabeceras de firma que PlanVortex manda, con el HMAC del cuerpo crudo hecho con el\n * `client_secret` de la app. El valor lleva el algoritmo delante: `sha256=<hex>`.\n *\n * Verifica la de 256 si puedes; la de sha1 está por compatibilidad con quien ya integraba webhooks\n * al estilo de Meta.\n */\nexport const WEBHOOK_SIGNATURE_HEADERS = {\n sha1: \"x-hub-signature\",\n sha256: \"x-hub-signature-256\",\n} as const;\n\n/** Los algoritmos con los que viaja firmada una entrega. */\nexport type WebhookAlgorithm = keyof typeof WEBHOOK_SIGNATURE_HEADERS;\n\n/**\n * Los eventos que hoy se entregan de verdad, comprobados uno a uno contra el servidor.\n *\n * Salen de dos sitios: `ALLOWED_WEBHOOKS_NOTIFICATIONS` para los que levanta PlanVortex\n * (`new_account`, `change_state_account`, `integration_error`) y `WEBHOOKS_TO_HANDLE` para los que\n * llegan de la red y sobreviven al filtro. **La lista crece**, así que un `field` desconocido se\n * ignora en vez de romper: para eso está {@link UnknownWebhookChange}.\n */\nexport const WEBHOOK_EVENTS = [\n \"new_account\",\n \"change_state_account\",\n \"messages\",\n \"messaging_postbacks\",\n \"messaging_seen\",\n \"messaging_error\",\n \"comments\",\n \"integration_error\",\n] as const;\n\nexport type WebhookEvent = (typeof WEBHOOK_EVENTS)[number];\n\n// -------------------------------------------------------------------------------------------\n// Los tipos de los eventos\n// -------------------------------------------------------------------------------------------\n\n/** Lo que trae todo cambio que cuelga de una CUENTA, pase lo que pase. */\nexport interface AccountWebhookChangeBase {\n id_account: string;\n id_organization: string;\n social_network: SocialNetwork;\n /**\n * El cambio tal y como lo mandó la red social, sin tocar. Ausente en los que levanta\n * PlanVortex por su cuenta, como `new_account`.\n */\n originalChange?: Record<string, unknown>;\n}\n\n/** Una cuenta se conectó, o cambió de estado: dejó de funcionar, se refrescó, se desconectó. */\nexport interface AccountStateChange extends AccountWebhookChangeBase {\n field: \"new_account\" | \"change_state_account\";\n}\n\n/**\n * Algo pasó en la mensajería: llegó un mensaje, el contacto pulsó un botón, leyó la conversación,\n * o la red rechazó uno de los nuestros.\n *\n * `messageObj` llega **poblado** —`contact_id`, `from_contact_id` y `message_options.files` traen\n * el objeto entero— y puede **faltar** en `messaging_seen` y `messaging_error`, porque el mensaje\n * que se reconoce puede no ser uno de los nuestros. Para leerlo sin escribir el `typeof` en cada\n * sitio están `messageContact`, `messageContactId` y `messageFiles` del paquete principal.\n */\nexport interface MessageChange extends AccountWebhookChangeBase {\n field: \"messages\" | \"messaging_postbacks\" | \"messaging_seen\" | \"messaging_error\";\n messageObj?: Message;\n /** El contacto del otro lado. Un comentario no tiene, y por eso aquí sí y allí no. */\n id_contact?: string;\n}\n\n/**\n * Llegó un comentario.\n *\n * Viaja en `commentObj` y **nunca** en `messageObj`: un comentario no es un mensaje, no tiene\n * contacto y cuelga de una publicación. Falta cuando el autor borró uno que no teníamos: el aviso\n * sale igual, con `originalChange`, pero no hay nada que marcar.\n *\n * **Meta repite entregas.** El mismo comentario puede llegar más de una vez; deduplica por\n * `commentObj.external_id`.\n */\nexport interface CommentChange extends AccountWebhookChangeBase {\n field: \"comments\";\n commentObj?: Comment;\n}\n\n/**\n * Una integración dejó de funcionar: un token de Drive revocado, un feed que ya no contesta, el\n * cupo de publicaciones agotado.\n *\n * **No trae `id_account` ni `social_network`**, y por eso es un tipo aparte: una integración cuelga\n * de la organización, no de ninguna cuenta.\n */\nexport interface IntegrationErrorChange {\n field: \"integration_error\";\n id_integration: string;\n id_organization: string;\n /** `google_drive` o `rss` hoy. La lista crece. */\n provider: string;\n /** El código del catálogo de PlanVortex que dice qué pasó. Los de integraciones van del 2200 al 2299. */\n error_code: number;\n}\n\n/**\n * Un `field` que esta versión del paquete todavía no conoce.\n *\n * Está en la unión a propósito: el enum del servidor crece y una librería que rechazara lo que no\n * entiende rompería el día que se añade un evento. Si necesitas los campos de uno nuevo antes de\n * que el paquete los tipe, haz el `cast` tú.\n */\nexport interface UnknownWebhookChange {\n field: string;\n}\n\n/**\n * Un cambio de los que llegan en el array.\n *\n * Se discrimina por `field`. Ojo con el `switch`: como {@link UnknownWebhookChange} declara\n * `field: string`, TypeScript no lo puede descartar de una rama concreta, así que un `case` te deja\n * `CommentChange | UnknownWebhookChange`. Para estrechar de verdad están los predicados\n * {@link isCommentChange} y compañía, que es la forma recomendada.\n */\nexport type WebhookChange =\n AccountStateChange | MessageChange | CommentChange | IntegrationErrorChange | UnknownWebhookChange;\n\nconst ACCOUNT_STATE_EVENTS = new Set<string>([\"new_account\", \"change_state_account\"]);\nconst MESSAGE_EVENTS = new Set<string>([\n \"messages\",\n \"messaging_postbacks\",\n \"messaging_seen\",\n \"messaging_error\",\n]);\n\n/** Una cuenta se conectó o cambió de estado. */\nexport function isAccountStateChange(change: WebhookChange): change is AccountStateChange {\n return ACCOUNT_STATE_EVENTS.has(change.field);\n}\n\n/** Algo pasó en la mensajería. Incluye el visto y el rechazo de la red, no sólo el mensaje nuevo. */\nexport function isMessageChange(change: WebhookChange): change is MessageChange {\n return MESSAGE_EVENTS.has(change.field);\n}\n\n/** Llegó un comentario. */\nexport function isCommentChange(change: WebhookChange): change is CommentChange {\n return change.field === \"comments\";\n}\n\n/** Una integración dejó de funcionar. */\nexport function isIntegrationErrorChange(change: WebhookChange): change is IntegrationErrorChange {\n return change.field === \"integration_error\";\n}\n\n// -------------------------------------------------------------------------------------------\n// Errores\n// -------------------------------------------------------------------------------------------\n\n/**\n * La firma no cuadra, o no venía ninguna.\n *\n * Hereda de `PlanVortexError` para que un `catch (e) { if (e instanceof PlanVortexError) }` los\n * coja todos. Lleva `family: \"webhook\"` y {@link NO_ERROR_CODE}, porque no sale del catálogo del\n * servidor: lo levanta esta librería.\n */\nexport class WebhookSignatureError extends PlanVortexError {\n constructor(message: string) {\n super(NO_ERROR_CODE, message, { family: \"webhook\" });\n }\n}\n\n/**\n * El cuerpo no es lo que tiene que ser: no son bytes crudos, no es JSON, o no es un array.\n *\n * El caso que se lleva casi todas las apariciones es el primero, y siempre por lo mismo: un\n * `express.json()` delante en vez de un `express.raw()`.\n */\nexport class WebhookBodyError extends PlanVortexError {\n constructor(message: string) {\n super(NO_ERROR_CODE, message, { family: \"webhook\" });\n }\n}\n\n// -------------------------------------------------------------------------------------------\n// Verificación\n// -------------------------------------------------------------------------------------------\n\n/** El cuerpo crudo, en cualquiera de las formas en que un framework lo deja a mano. */\nexport type RawWebhookBody = string | Buffer | Uint8Array;\n\nexport interface VerifyWebhookSignatureOptions {\n /** El cuerpo **crudo**, tal y como llegó. Un objeto ya parseado no vale y lanza. */\n payload: RawWebhookBody;\n /** El valor de la cabecera, con `sha256=` delante o sin él. */\n signature: string | string[] | undefined;\n /** El `client_secret` de tu app: el mismo con el que pides el token. */\n secret: string;\n /** Con cuál de las dos cabeceras estás comparando. Por defecto `sha256`. */\n algorithm?: WebhookAlgorithm;\n}\n\n/**\n * ¿Firmó PlanVortex este cuerpo con el secreto de tu app?\n *\n * Devuelve `true` o `false` y **no lanza** por una firma mal formada, ausente o de otra longitud:\n * todo eso es un `false`. Lanza sólo si le pasas algo que no son bytes (§ {@link WebhookBodyError})\n * o te dejas el secreto, que son fallos de tu código y no del que llama a tu endpoint.\n *\n * La comparación es de tiempo constante (`crypto.timingSafeEqual`). Las longitudes se comprueban\n * antes: el HMAC de un algoritmo siempre mide lo mismo, así que la longitud no es secreto, y sin\n * esa comprobación `timingSafeEqual` **lanzaría** ante una firma recortada — que es exactamente lo\n * que mandaría alguien probando el endpoint.\n */\nexport function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): boolean {\n const { payload, signature, secret, algorithm = \"sha256\" } = options;\n if (!secret) {\n throw new WebhookBodyError(\"Falta el client_secret con el que verificar la firma.\");\n }\n const body = toBuffer(payload);\n const received = normalizeSignature(signature, algorithm);\n if (!received) {\n return false;\n }\n const expected = crypto.createHmac(algorithm, secret).update(body).digest(\"hex\");\n if (expected.length !== received.length) {\n return false;\n }\n return crypto.timingSafeEqual(Buffer.from(expected, \"utf8\"), Buffer.from(received, \"utf8\"));\n}\n\n/**\n * Deja la firma en hex pelado, o `undefined` si no sirve.\n *\n * Acepta `sha256=<hex>` y el hex a secas, pero **rechaza el prefijo de otro algoritmo**: comparar\n * un sha1 contra el sha256 esperado no cuadraría nunca, y devolver `false` sin más escondería que\n * lo que pasa es que se está leyendo la cabecera equivocada.\n */\nfunction normalizeSignature(\n signature: string | string[] | undefined,\n algorithm: WebhookAlgorithm,\n): string | undefined {\n const value = Array.isArray(signature) ? signature[0] : signature;\n if (typeof value !== \"string\" || !value) {\n return undefined;\n }\n const separator = value.indexOf(\"=\");\n if (separator === -1) {\n return value;\n }\n return value.slice(0, separator) === algorithm ? value.slice(separator + 1) : undefined;\n}\n\nfunction toBuffer(payload: RawWebhookBody): Buffer {\n if (typeof payload === \"string\") {\n return Buffer.from(payload, \"utf8\");\n }\n if (Buffer.isBuffer(payload)) {\n return payload;\n }\n if (payload instanceof Uint8Array) {\n return Buffer.from(payload);\n }\n throw new WebhookBodyError(\n \"El cuerpo del webhook tiene que ser los BYTES que llegaron (Buffer, Uint8Array o string), \" +\n \"no el JSON ya parseado: la firma se calcula sobre esos bytes y volver a serializar el \" +\n 'objeto los cambia. En Express: express.raw({ type: \"application/json\" }).',\n );\n}\n\n// -------------------------------------------------------------------------------------------\n// El camino agnóstico: bytes + cabeceras -> cambios\n// -------------------------------------------------------------------------------------------\n\n/** Las cabeceras tal y como las da cada framework: un objeto plano o un `Headers` de fetch. */\nexport type WebhookHeaders =\n Record<string, string | string[] | undefined> | { get(name: string): string | null };\n\nexport interface HandleWebhookRequestOptions {\n /** El cuerpo **crudo**. En Hono, Next o Fastify: `await request.text()`. */\n body: RawWebhookBody;\n headers: WebhookHeaders;\n /** El `client_secret` de tu app. */\n secret: string;\n /**\n * Con qué cabecera verificar. Por defecto se usa la de `sha256` si viene y se cae a la de\n * `sha1` sólo si no está.\n */\n algorithm?: WebhookAlgorithm;\n}\n\n/**\n * Verifica la firma y devuelve los cambios, sin saber nada de tu framework.\n *\n * Es lo que hay debajo de {@link planvortexWebhooks} y lo que se usa fuera de Express:\n *\n * ```ts\n * const changes = handleWebhookRequest({\n * body: await request.text(),\n * headers: request.headers,\n * secret: process.env.PLANVORTEX_CLIENT_SECRET!,\n * });\n * ```\n *\n * @throws {WebhookSignatureError} si no viene firma o no cuadra.\n * @throws {WebhookBodyError} si el cuerpo no son bytes, no es JSON, o no es un array.\n */\nexport function handleWebhookRequest(options: HandleWebhookRequestOptions): WebhookChange[] {\n const { body, headers, secret, algorithm } = options;\n\n const chosen = algorithm ?? (readHeader(headers, WEBHOOK_SIGNATURE_HEADERS.sha256) ? \"sha256\" : \"sha1\");\n const signature = readHeader(headers, WEBHOOK_SIGNATURE_HEADERS[chosen]);\n if (!signature) {\n throw new WebhookSignatureError(\n `La entrega no trae la cabecera ${WEBHOOK_SIGNATURE_HEADERS[chosen]}. ` +\n \"Si estás detrás de un proxy, comprueba que no la esté quitando.\",\n );\n }\n if (!verifyWebhookSignature({ payload: body, signature, secret, algorithm: chosen })) {\n throw new WebhookSignatureError(\n `La firma ${WEBHOOK_SIGNATURE_HEADERS[chosen]} no cuadra con el cuerpo recibido. ` +\n \"Las dos causas de siempre: el cuerpo no es el crudo, o el secreto no es el de esta app.\",\n );\n }\n return parseWebhookPayload(body);\n}\n\n/**\n * Parsea el cuerpo de una entrega **ya verificada** y devuelve los cambios.\n *\n * Sepárala de la verificación sólo si tienes una razón: llamarla a secas es aceptar cualquier\n * cosa que llegue a tu endpoint. Comprueba que sea un array porque el error natural —tratar el\n * cuerpo como un objeto— no da ningún síntoma hasta que se lee un campo que siempre es `undefined`.\n */\nexport function parseWebhookPayload(body: RawWebhookBody): WebhookChange[] {\n const text = toBuffer(body).toString(\"utf8\");\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch (cause) {\n throw new WebhookBodyError(`El cuerpo del webhook no es JSON válido: ${(cause as Error).message}`);\n }\n if (!Array.isArray(parsed)) {\n throw new WebhookBodyError(\n \"El cuerpo del webhook es un ARRAY de cambios, y ha llegado \" +\n `${parsed === null ? \"null\" : typeof parsed}. Recorre lo que llega.`,\n );\n }\n return parsed as WebhookChange[];\n}\n\n/** Lee una cabecera de un objeto plano o de un `Headers`, sin depender de mayúsculas. */\nfunction readHeader(headers: WebhookHeaders, name: string): string | undefined {\n if (headers && typeof (headers as { get?: unknown }).get === \"function\") {\n return (headers as { get(header: string): string | null }).get(name) ?? undefined;\n }\n const record = headers as Record<string, string | string[] | undefined>;\n const direct = record[name] ?? record[name.toLowerCase()];\n const found =\n direct ?? Object.entries(record).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];\n return Array.isArray(found) ? found[0] : found;\n}\n\n// -------------------------------------------------------------------------------------------\n// El middleware de Express\n// -------------------------------------------------------------------------------------------\n\n/**\n * Lo mínimo que este middleware necesita de una petición.\n *\n * Se declara aquí en vez de importar los tipos de Express **para no depender de Express**: el\n * paquete no tiene dependencias de runtime y no va a tener una de tipos. Un `Request` de verdad\n * cumple esto de sobra, así que el middleware encaja en `app.post(...)` sin un solo `cast`.\n */\nexport interface WebhookRequestLike {\n headers: Record<string, string | string[] | undefined>;\n /** Lo que haya dejado el body parser, si había alguno. */\n body?: unknown;\n}\n\n/** Lo mínimo que este middleware necesita de una respuesta. Vale un `Response` de Express y un `ServerResponse` pelado. */\nexport interface WebhookResponseLike {\n statusCode: number;\n end(chunk?: string): void;\n}\n\nexport interface PlanVortexWebhooksOptions {\n /** El `client_secret` de tu app. */\n secret: string;\n /**\n * Qué hacer con los cambios. Se espera a que termine antes de responder, así que si tu trabajo\n * es lento, encólalo aquí y vuelve: PlanVortex no reintenta una entrega que se cae.\n */\n onChanges: (changes: WebhookChange[], request: WebhookRequestLike) => void | Promise<void>;\n /** Para enterarte de una firma que no cuadra, que si no es silenciosa. */\n onError?: (error: Error, request: WebhookRequestLike) => void;\n /** Fuerza una de las dos cabeceras. Por defecto `sha256` si viene, y `sha1` si no. */\n algorithm?: WebhookAlgorithm;\n}\n\n/**\n * Middleware de Express que verifica la firma y te entrega los cambios.\n *\n * ```ts\n * app.post(\n * \"/webhooks/planvortex\",\n * planvortexWebhooks({\n * secret: process.env.PLANVORTEX_CLIENT_SECRET!,\n * onChanges: async (changes) => {\n * for (const change of changes) {\n * if (isCommentChange(change)) await moderate(change.commentObj);\n * }\n * },\n * }),\n * );\n * ```\n *\n * **No hace falta `express.raw()` delante**: si no encuentra cuerpo, lee el flujo él mismo. Lo que\n * no puede es arreglar un `express.json()` global, porque ése ya se bebió los bytes y los devuelve\n * convertidos en objeto — ahí responde 400 diciendo exactamente eso.\n *\n * Responde él: 200 si todo fue bien, 401 si la firma no cuadra o falta, 400 si el cuerpo no sirve,\n * y 500 si tu `onChanges` lanza (que además llega a `next`, para que lo vea tu manejador de\n * errores). Por eso va al final de la cadena y no lleva un `next()` de paso.\n */\nexport function planvortexWebhooks(\n options: PlanVortexWebhooksOptions,\n): (request: WebhookRequestLike, response: WebhookResponseLike, next: (error?: unknown) => void) => void {\n const { secret, onChanges, onError, algorithm } = options;\n\n return (request, response, next) => {\n void (async () => {\n let changes: WebhookChange[];\n try {\n const body = await resolveRawBody(request);\n changes = handleWebhookRequest({\n body,\n headers: request.headers,\n secret,\n ...(algorithm ? { algorithm } : {}),\n });\n } catch (error) {\n onError?.(error as Error, request);\n response.statusCode = error instanceof WebhookSignatureError ? 401 : 400;\n response.end();\n return;\n }\n\n try {\n await onChanges(changes, request);\n } catch (error) {\n onError?.(error as Error, request);\n response.statusCode = 500;\n response.end();\n next(error);\n return;\n }\n\n response.statusCode = 200;\n response.end();\n })();\n };\n}\n\n/**\n * Los bytes de la petición, vengan de donde vengan.\n *\n * Tres casos, y el tercero es el que más disgustos da: si `body` ya es un objeto, alguien parseó\n * el JSON antes y los bytes originales **ya no existen**. No se puede recuperar volviendo a\n * serializar —el orden de las claves y los espacios no tienen por qué coincidir— así que lo único\n * honesto es decirlo.\n */\nasync function resolveRawBody(request: WebhookRequestLike): Promise<RawWebhookBody> {\n const body = request.body;\n if (typeof body === \"string\" || Buffer.isBuffer(body) || body instanceof Uint8Array) {\n return body;\n }\n if (body === undefined || body === null) {\n const streamed = await readStream(request);\n if (streamed) {\n return streamed;\n }\n }\n return toBuffer(body as RawWebhookBody);\n}\n\nasync function readStream(request: WebhookRequestLike): Promise<Buffer | undefined> {\n const iterable = request as unknown as AsyncIterable<Uint8Array>;\n if (typeof iterable?.[Symbol.asyncIterator] !== \"function\") {\n return undefined;\n }\n const chunks: Uint8Array[] = [];\n for await (const chunk of iterable) {\n chunks.push(chunk);\n }\n return Buffer.concat(chunks);\n}\n"]}
@@ -0,0 +1 @@
1
+ export { s as AccountStateChange, t as AccountWebhookChangeBase, H as CommentChange, aD as HandleWebhookRequestOptions, X as IntegrationErrorChange, Z as MessageChange, aE as PlanVortexWebhooksOptions, aF as RawWebhookBody, ao as UnknownWebhookChange, aG as VerifyWebhookSignatureOptions, aH as WEBHOOK_EVENTS, aI as WEBHOOK_SIGNATURE_HEADERS, aq as WebhookAlgorithm, aJ as WebhookBodyError, ar as WebhookChange, as as WebhookEvent, aK as WebhookHeaders, aL as WebhookRequestLike, aM as WebhookResponseLike, aN as WebhookSignatureError, aO as handleWebhookRequest, aP as isAccountStateChange, aQ as isCommentChange, aR as isIntegrationErrorChange, aS as isMessageChange, aT as parseWebhookPayload, aU as planvortexWebhooks, aV as verifyWebhookSignature } from '../index-CUrq0B7g.cjs';
@@ -0,0 +1 @@
1
+ export { s as AccountStateChange, t as AccountWebhookChangeBase, H as CommentChange, aD as HandleWebhookRequestOptions, X as IntegrationErrorChange, Z as MessageChange, aE as PlanVortexWebhooksOptions, aF as RawWebhookBody, ao as UnknownWebhookChange, aG as VerifyWebhookSignatureOptions, aH as WEBHOOK_EVENTS, aI as WEBHOOK_SIGNATURE_HEADERS, aq as WebhookAlgorithm, aJ as WebhookBodyError, ar as WebhookChange, as as WebhookEvent, aK as WebhookHeaders, aL as WebhookRequestLike, aM as WebhookResponseLike, aN as WebhookSignatureError, aO as handleWebhookRequest, aP as isAccountStateChange, aQ as isCommentChange, aR as isIntegrationErrorChange, aS as isMessageChange, aT as parseWebhookPayload, aU as planvortexWebhooks, aV as verifyWebhookSignature } from '../index-CUrq0B7g.js';
@@ -0,0 +1,188 @@
1
+ import { PlanVortexError, NO_ERROR_CODE } from '../chunk-B4DEHU6Q.js';
2
+ import crypto from 'crypto';
3
+
4
+ var WEBHOOK_SIGNATURE_HEADERS = {
5
+ sha1: "x-hub-signature",
6
+ sha256: "x-hub-signature-256"
7
+ };
8
+ var WEBHOOK_EVENTS = [
9
+ "new_account",
10
+ "change_state_account",
11
+ "messages",
12
+ "messaging_postbacks",
13
+ "messaging_seen",
14
+ "messaging_error",
15
+ "comments",
16
+ "integration_error"
17
+ ];
18
+ var ACCOUNT_STATE_EVENTS = /* @__PURE__ */ new Set(["new_account", "change_state_account"]);
19
+ var MESSAGE_EVENTS = /* @__PURE__ */ new Set([
20
+ "messages",
21
+ "messaging_postbacks",
22
+ "messaging_seen",
23
+ "messaging_error"
24
+ ]);
25
+ function isAccountStateChange(change) {
26
+ return ACCOUNT_STATE_EVENTS.has(change.field);
27
+ }
28
+ function isMessageChange(change) {
29
+ return MESSAGE_EVENTS.has(change.field);
30
+ }
31
+ function isCommentChange(change) {
32
+ return change.field === "comments";
33
+ }
34
+ function isIntegrationErrorChange(change) {
35
+ return change.field === "integration_error";
36
+ }
37
+ var WebhookSignatureError = class extends PlanVortexError {
38
+ constructor(message) {
39
+ super(NO_ERROR_CODE, message, { family: "webhook" });
40
+ }
41
+ };
42
+ var WebhookBodyError = class extends PlanVortexError {
43
+ constructor(message) {
44
+ super(NO_ERROR_CODE, message, { family: "webhook" });
45
+ }
46
+ };
47
+ function verifyWebhookSignature(options) {
48
+ const { payload, signature, secret, algorithm = "sha256" } = options;
49
+ if (!secret) {
50
+ throw new WebhookBodyError("Falta el client_secret con el que verificar la firma.");
51
+ }
52
+ const body = toBuffer(payload);
53
+ const received = normalizeSignature(signature, algorithm);
54
+ if (!received) {
55
+ return false;
56
+ }
57
+ const expected = crypto.createHmac(algorithm, secret).update(body).digest("hex");
58
+ if (expected.length !== received.length) {
59
+ return false;
60
+ }
61
+ return crypto.timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(received, "utf8"));
62
+ }
63
+ function normalizeSignature(signature, algorithm) {
64
+ const value = Array.isArray(signature) ? signature[0] : signature;
65
+ if (typeof value !== "string" || !value) {
66
+ return void 0;
67
+ }
68
+ const separator = value.indexOf("=");
69
+ if (separator === -1) {
70
+ return value;
71
+ }
72
+ return value.slice(0, separator) === algorithm ? value.slice(separator + 1) : void 0;
73
+ }
74
+ function toBuffer(payload) {
75
+ if (typeof payload === "string") {
76
+ return Buffer.from(payload, "utf8");
77
+ }
78
+ if (Buffer.isBuffer(payload)) {
79
+ return payload;
80
+ }
81
+ if (payload instanceof Uint8Array) {
82
+ return Buffer.from(payload);
83
+ }
84
+ throw new WebhookBodyError(
85
+ 'El cuerpo del webhook tiene que ser los BYTES que llegaron (Buffer, Uint8Array o string), no el JSON ya parseado: la firma se calcula sobre esos bytes y volver a serializar el objeto los cambia. En Express: express.raw({ type: "application/json" }).'
86
+ );
87
+ }
88
+ function handleWebhookRequest(options) {
89
+ const { body, headers, secret, algorithm } = options;
90
+ const chosen = algorithm ?? (readHeader(headers, WEBHOOK_SIGNATURE_HEADERS.sha256) ? "sha256" : "sha1");
91
+ const signature = readHeader(headers, WEBHOOK_SIGNATURE_HEADERS[chosen]);
92
+ if (!signature) {
93
+ throw new WebhookSignatureError(
94
+ `La entrega no trae la cabecera ${WEBHOOK_SIGNATURE_HEADERS[chosen]}. Si est\xE1s detr\xE1s de un proxy, comprueba que no la est\xE9 quitando.`
95
+ );
96
+ }
97
+ if (!verifyWebhookSignature({ payload: body, signature, secret, algorithm: chosen })) {
98
+ throw new WebhookSignatureError(
99
+ `La firma ${WEBHOOK_SIGNATURE_HEADERS[chosen]} no cuadra con el cuerpo recibido. Las dos causas de siempre: el cuerpo no es el crudo, o el secreto no es el de esta app.`
100
+ );
101
+ }
102
+ return parseWebhookPayload(body);
103
+ }
104
+ function parseWebhookPayload(body) {
105
+ const text = toBuffer(body).toString("utf8");
106
+ let parsed;
107
+ try {
108
+ parsed = JSON.parse(text);
109
+ } catch (cause) {
110
+ throw new WebhookBodyError(`El cuerpo del webhook no es JSON v\xE1lido: ${cause.message}`);
111
+ }
112
+ if (!Array.isArray(parsed)) {
113
+ throw new WebhookBodyError(
114
+ `El cuerpo del webhook es un ARRAY de cambios, y ha llegado ${parsed === null ? "null" : typeof parsed}. Recorre lo que llega.`
115
+ );
116
+ }
117
+ return parsed;
118
+ }
119
+ function readHeader(headers, name) {
120
+ if (headers && typeof headers.get === "function") {
121
+ return headers.get(name) ?? void 0;
122
+ }
123
+ const record = headers;
124
+ const direct = record[name] ?? record[name.toLowerCase()];
125
+ const found = direct ?? Object.entries(record).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
126
+ return Array.isArray(found) ? found[0] : found;
127
+ }
128
+ function planvortexWebhooks(options) {
129
+ const { secret, onChanges, onError, algorithm } = options;
130
+ return (request, response, next) => {
131
+ void (async () => {
132
+ let changes;
133
+ try {
134
+ const body = await resolveRawBody(request);
135
+ changes = handleWebhookRequest({
136
+ body,
137
+ headers: request.headers,
138
+ secret,
139
+ ...algorithm ? { algorithm } : {}
140
+ });
141
+ } catch (error) {
142
+ onError?.(error, request);
143
+ response.statusCode = error instanceof WebhookSignatureError ? 401 : 400;
144
+ response.end();
145
+ return;
146
+ }
147
+ try {
148
+ await onChanges(changes, request);
149
+ } catch (error) {
150
+ onError?.(error, request);
151
+ response.statusCode = 500;
152
+ response.end();
153
+ next(error);
154
+ return;
155
+ }
156
+ response.statusCode = 200;
157
+ response.end();
158
+ })();
159
+ };
160
+ }
161
+ async function resolveRawBody(request) {
162
+ const body = request.body;
163
+ if (typeof body === "string" || Buffer.isBuffer(body) || body instanceof Uint8Array) {
164
+ return body;
165
+ }
166
+ if (body === void 0 || body === null) {
167
+ const streamed = await readStream(request);
168
+ if (streamed) {
169
+ return streamed;
170
+ }
171
+ }
172
+ return toBuffer(body);
173
+ }
174
+ async function readStream(request) {
175
+ const iterable = request;
176
+ if (typeof iterable?.[Symbol.asyncIterator] !== "function") {
177
+ return void 0;
178
+ }
179
+ const chunks = [];
180
+ for await (const chunk of iterable) {
181
+ chunks.push(chunk);
182
+ }
183
+ return Buffer.concat(chunks);
184
+ }
185
+
186
+ export { WEBHOOK_EVENTS, WEBHOOK_SIGNATURE_HEADERS, WebhookBodyError, WebhookSignatureError, handleWebhookRequest, isAccountStateChange, isCommentChange, isIntegrationErrorChange, isMessageChange, parseWebhookPayload, planvortexWebhooks, verifyWebhookSignature };
187
+ //# sourceMappingURL=index.js.map
188
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/webhooks/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * `planvortex/webhooks` — los eventos que PlanVortex manda a tu app, y cómo comprobar que son suyos.\n *\n * Va en su propio punto de entrada porque quien recibe webhooks casi nunca es el mismo proceso que\n * publica: un endpoint de Express no tiene por qué cargar el cliente entero.\n *\n * DOS COSAS QUE TUMBAN A TODO EL MUNDO, y por eso están escritas antes que el código:\n *\n * 1. **El cuerpo es un ARRAY de cambios**, no un objeto. Recorre lo que llega.\n * 2. **La firma se calcula sobre el cuerpo CRUDO.** Si tu framework ya parseó el JSON y lo vuelves\n * a serializar, los bytes no son los mismos y la firma no cuadra nunca. En Express hace falta\n * `express.raw({ type: \"application/json\" })` delante — o dejar que\n * {@link planvortexWebhooks} lea el flujo él mismo, que es lo que hace si no encuentra cuerpo.\n *\n * Y una tercera que no tumba, pero cuesta dinero: **PlanVortex no reintenta una entrega fallida.**\n * Un 500 tuyo pierde el evento. Si tu trabajo es lento, encólalo y responde.\n */\nimport crypto from \"node:crypto\";\n\nimport { NO_ERROR_CODE, PlanVortexError } from \"../core/errors.js\";\nimport type { Comment, Message, SocialNetwork } from \"../types.js\";\n\n// -------------------------------------------------------------------------------------------\n// El contrato: cabeceras y eventos\n// -------------------------------------------------------------------------------------------\n\n/**\n * Las dos cabeceras de firma que PlanVortex manda, con el HMAC del cuerpo crudo hecho con el\n * `client_secret` de la app. El valor lleva el algoritmo delante: `sha256=<hex>`.\n *\n * Verifica la de 256 si puedes; la de sha1 está por compatibilidad con quien ya integraba webhooks\n * al estilo de Meta.\n */\nexport const WEBHOOK_SIGNATURE_HEADERS = {\n sha1: \"x-hub-signature\",\n sha256: \"x-hub-signature-256\",\n} as const;\n\n/** Los algoritmos con los que viaja firmada una entrega. */\nexport type WebhookAlgorithm = keyof typeof WEBHOOK_SIGNATURE_HEADERS;\n\n/**\n * Los eventos que hoy se entregan de verdad, comprobados uno a uno contra el servidor.\n *\n * Salen de dos sitios: `ALLOWED_WEBHOOKS_NOTIFICATIONS` para los que levanta PlanVortex\n * (`new_account`, `change_state_account`, `integration_error`) y `WEBHOOKS_TO_HANDLE` para los que\n * llegan de la red y sobreviven al filtro. **La lista crece**, así que un `field` desconocido se\n * ignora en vez de romper: para eso está {@link UnknownWebhookChange}.\n */\nexport const WEBHOOK_EVENTS = [\n \"new_account\",\n \"change_state_account\",\n \"messages\",\n \"messaging_postbacks\",\n \"messaging_seen\",\n \"messaging_error\",\n \"comments\",\n \"integration_error\",\n] as const;\n\nexport type WebhookEvent = (typeof WEBHOOK_EVENTS)[number];\n\n// -------------------------------------------------------------------------------------------\n// Los tipos de los eventos\n// -------------------------------------------------------------------------------------------\n\n/** Lo que trae todo cambio que cuelga de una CUENTA, pase lo que pase. */\nexport interface AccountWebhookChangeBase {\n id_account: string;\n id_organization: string;\n social_network: SocialNetwork;\n /**\n * El cambio tal y como lo mandó la red social, sin tocar. Ausente en los que levanta\n * PlanVortex por su cuenta, como `new_account`.\n */\n originalChange?: Record<string, unknown>;\n}\n\n/** Una cuenta se conectó, o cambió de estado: dejó de funcionar, se refrescó, se desconectó. */\nexport interface AccountStateChange extends AccountWebhookChangeBase {\n field: \"new_account\" | \"change_state_account\";\n}\n\n/**\n * Algo pasó en la mensajería: llegó un mensaje, el contacto pulsó un botón, leyó la conversación,\n * o la red rechazó uno de los nuestros.\n *\n * `messageObj` llega **poblado** —`contact_id`, `from_contact_id` y `message_options.files` traen\n * el objeto entero— y puede **faltar** en `messaging_seen` y `messaging_error`, porque el mensaje\n * que se reconoce puede no ser uno de los nuestros. Para leerlo sin escribir el `typeof` en cada\n * sitio están `messageContact`, `messageContactId` y `messageFiles` del paquete principal.\n */\nexport interface MessageChange extends AccountWebhookChangeBase {\n field: \"messages\" | \"messaging_postbacks\" | \"messaging_seen\" | \"messaging_error\";\n messageObj?: Message;\n /** El contacto del otro lado. Un comentario no tiene, y por eso aquí sí y allí no. */\n id_contact?: string;\n}\n\n/**\n * Llegó un comentario.\n *\n * Viaja en `commentObj` y **nunca** en `messageObj`: un comentario no es un mensaje, no tiene\n * contacto y cuelga de una publicación. Falta cuando el autor borró uno que no teníamos: el aviso\n * sale igual, con `originalChange`, pero no hay nada que marcar.\n *\n * **Meta repite entregas.** El mismo comentario puede llegar más de una vez; deduplica por\n * `commentObj.external_id`.\n */\nexport interface CommentChange extends AccountWebhookChangeBase {\n field: \"comments\";\n commentObj?: Comment;\n}\n\n/**\n * Una integración dejó de funcionar: un token de Drive revocado, un feed que ya no contesta, el\n * cupo de publicaciones agotado.\n *\n * **No trae `id_account` ni `social_network`**, y por eso es un tipo aparte: una integración cuelga\n * de la organización, no de ninguna cuenta.\n */\nexport interface IntegrationErrorChange {\n field: \"integration_error\";\n id_integration: string;\n id_organization: string;\n /** `google_drive` o `rss` hoy. La lista crece. */\n provider: string;\n /** El código del catálogo de PlanVortex que dice qué pasó. Los de integraciones van del 2200 al 2299. */\n error_code: number;\n}\n\n/**\n * Un `field` que esta versión del paquete todavía no conoce.\n *\n * Está en la unión a propósito: el enum del servidor crece y una librería que rechazara lo que no\n * entiende rompería el día que se añade un evento. Si necesitas los campos de uno nuevo antes de\n * que el paquete los tipe, haz el `cast` tú.\n */\nexport interface UnknownWebhookChange {\n field: string;\n}\n\n/**\n * Un cambio de los que llegan en el array.\n *\n * Se discrimina por `field`. Ojo con el `switch`: como {@link UnknownWebhookChange} declara\n * `field: string`, TypeScript no lo puede descartar de una rama concreta, así que un `case` te deja\n * `CommentChange | UnknownWebhookChange`. Para estrechar de verdad están los predicados\n * {@link isCommentChange} y compañía, que es la forma recomendada.\n */\nexport type WebhookChange =\n AccountStateChange | MessageChange | CommentChange | IntegrationErrorChange | UnknownWebhookChange;\n\nconst ACCOUNT_STATE_EVENTS = new Set<string>([\"new_account\", \"change_state_account\"]);\nconst MESSAGE_EVENTS = new Set<string>([\n \"messages\",\n \"messaging_postbacks\",\n \"messaging_seen\",\n \"messaging_error\",\n]);\n\n/** Una cuenta se conectó o cambió de estado. */\nexport function isAccountStateChange(change: WebhookChange): change is AccountStateChange {\n return ACCOUNT_STATE_EVENTS.has(change.field);\n}\n\n/** Algo pasó en la mensajería. Incluye el visto y el rechazo de la red, no sólo el mensaje nuevo. */\nexport function isMessageChange(change: WebhookChange): change is MessageChange {\n return MESSAGE_EVENTS.has(change.field);\n}\n\n/** Llegó un comentario. */\nexport function isCommentChange(change: WebhookChange): change is CommentChange {\n return change.field === \"comments\";\n}\n\n/** Una integración dejó de funcionar. */\nexport function isIntegrationErrorChange(change: WebhookChange): change is IntegrationErrorChange {\n return change.field === \"integration_error\";\n}\n\n// -------------------------------------------------------------------------------------------\n// Errores\n// -------------------------------------------------------------------------------------------\n\n/**\n * La firma no cuadra, o no venía ninguna.\n *\n * Hereda de `PlanVortexError` para que un `catch (e) { if (e instanceof PlanVortexError) }` los\n * coja todos. Lleva `family: \"webhook\"` y {@link NO_ERROR_CODE}, porque no sale del catálogo del\n * servidor: lo levanta esta librería.\n */\nexport class WebhookSignatureError extends PlanVortexError {\n constructor(message: string) {\n super(NO_ERROR_CODE, message, { family: \"webhook\" });\n }\n}\n\n/**\n * El cuerpo no es lo que tiene que ser: no son bytes crudos, no es JSON, o no es un array.\n *\n * El caso que se lleva casi todas las apariciones es el primero, y siempre por lo mismo: un\n * `express.json()` delante en vez de un `express.raw()`.\n */\nexport class WebhookBodyError extends PlanVortexError {\n constructor(message: string) {\n super(NO_ERROR_CODE, message, { family: \"webhook\" });\n }\n}\n\n// -------------------------------------------------------------------------------------------\n// Verificación\n// -------------------------------------------------------------------------------------------\n\n/** El cuerpo crudo, en cualquiera de las formas en que un framework lo deja a mano. */\nexport type RawWebhookBody = string | Buffer | Uint8Array;\n\nexport interface VerifyWebhookSignatureOptions {\n /** El cuerpo **crudo**, tal y como llegó. Un objeto ya parseado no vale y lanza. */\n payload: RawWebhookBody;\n /** El valor de la cabecera, con `sha256=` delante o sin él. */\n signature: string | string[] | undefined;\n /** El `client_secret` de tu app: el mismo con el que pides el token. */\n secret: string;\n /** Con cuál de las dos cabeceras estás comparando. Por defecto `sha256`. */\n algorithm?: WebhookAlgorithm;\n}\n\n/**\n * ¿Firmó PlanVortex este cuerpo con el secreto de tu app?\n *\n * Devuelve `true` o `false` y **no lanza** por una firma mal formada, ausente o de otra longitud:\n * todo eso es un `false`. Lanza sólo si le pasas algo que no son bytes (§ {@link WebhookBodyError})\n * o te dejas el secreto, que son fallos de tu código y no del que llama a tu endpoint.\n *\n * La comparación es de tiempo constante (`crypto.timingSafeEqual`). Las longitudes se comprueban\n * antes: el HMAC de un algoritmo siempre mide lo mismo, así que la longitud no es secreto, y sin\n * esa comprobación `timingSafeEqual` **lanzaría** ante una firma recortada — que es exactamente lo\n * que mandaría alguien probando el endpoint.\n */\nexport function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): boolean {\n const { payload, signature, secret, algorithm = \"sha256\" } = options;\n if (!secret) {\n throw new WebhookBodyError(\"Falta el client_secret con el que verificar la firma.\");\n }\n const body = toBuffer(payload);\n const received = normalizeSignature(signature, algorithm);\n if (!received) {\n return false;\n }\n const expected = crypto.createHmac(algorithm, secret).update(body).digest(\"hex\");\n if (expected.length !== received.length) {\n return false;\n }\n return crypto.timingSafeEqual(Buffer.from(expected, \"utf8\"), Buffer.from(received, \"utf8\"));\n}\n\n/**\n * Deja la firma en hex pelado, o `undefined` si no sirve.\n *\n * Acepta `sha256=<hex>` y el hex a secas, pero **rechaza el prefijo de otro algoritmo**: comparar\n * un sha1 contra el sha256 esperado no cuadraría nunca, y devolver `false` sin más escondería que\n * lo que pasa es que se está leyendo la cabecera equivocada.\n */\nfunction normalizeSignature(\n signature: string | string[] | undefined,\n algorithm: WebhookAlgorithm,\n): string | undefined {\n const value = Array.isArray(signature) ? signature[0] : signature;\n if (typeof value !== \"string\" || !value) {\n return undefined;\n }\n const separator = value.indexOf(\"=\");\n if (separator === -1) {\n return value;\n }\n return value.slice(0, separator) === algorithm ? value.slice(separator + 1) : undefined;\n}\n\nfunction toBuffer(payload: RawWebhookBody): Buffer {\n if (typeof payload === \"string\") {\n return Buffer.from(payload, \"utf8\");\n }\n if (Buffer.isBuffer(payload)) {\n return payload;\n }\n if (payload instanceof Uint8Array) {\n return Buffer.from(payload);\n }\n throw new WebhookBodyError(\n \"El cuerpo del webhook tiene que ser los BYTES que llegaron (Buffer, Uint8Array o string), \" +\n \"no el JSON ya parseado: la firma se calcula sobre esos bytes y volver a serializar el \" +\n 'objeto los cambia. En Express: express.raw({ type: \"application/json\" }).',\n );\n}\n\n// -------------------------------------------------------------------------------------------\n// El camino agnóstico: bytes + cabeceras -> cambios\n// -------------------------------------------------------------------------------------------\n\n/** Las cabeceras tal y como las da cada framework: un objeto plano o un `Headers` de fetch. */\nexport type WebhookHeaders =\n Record<string, string | string[] | undefined> | { get(name: string): string | null };\n\nexport interface HandleWebhookRequestOptions {\n /** El cuerpo **crudo**. En Hono, Next o Fastify: `await request.text()`. */\n body: RawWebhookBody;\n headers: WebhookHeaders;\n /** El `client_secret` de tu app. */\n secret: string;\n /**\n * Con qué cabecera verificar. Por defecto se usa la de `sha256` si viene y se cae a la de\n * `sha1` sólo si no está.\n */\n algorithm?: WebhookAlgorithm;\n}\n\n/**\n * Verifica la firma y devuelve los cambios, sin saber nada de tu framework.\n *\n * Es lo que hay debajo de {@link planvortexWebhooks} y lo que se usa fuera de Express:\n *\n * ```ts\n * const changes = handleWebhookRequest({\n * body: await request.text(),\n * headers: request.headers,\n * secret: process.env.PLANVORTEX_CLIENT_SECRET!,\n * });\n * ```\n *\n * @throws {WebhookSignatureError} si no viene firma o no cuadra.\n * @throws {WebhookBodyError} si el cuerpo no son bytes, no es JSON, o no es un array.\n */\nexport function handleWebhookRequest(options: HandleWebhookRequestOptions): WebhookChange[] {\n const { body, headers, secret, algorithm } = options;\n\n const chosen = algorithm ?? (readHeader(headers, WEBHOOK_SIGNATURE_HEADERS.sha256) ? \"sha256\" : \"sha1\");\n const signature = readHeader(headers, WEBHOOK_SIGNATURE_HEADERS[chosen]);\n if (!signature) {\n throw new WebhookSignatureError(\n `La entrega no trae la cabecera ${WEBHOOK_SIGNATURE_HEADERS[chosen]}. ` +\n \"Si estás detrás de un proxy, comprueba que no la esté quitando.\",\n );\n }\n if (!verifyWebhookSignature({ payload: body, signature, secret, algorithm: chosen })) {\n throw new WebhookSignatureError(\n `La firma ${WEBHOOK_SIGNATURE_HEADERS[chosen]} no cuadra con el cuerpo recibido. ` +\n \"Las dos causas de siempre: el cuerpo no es el crudo, o el secreto no es el de esta app.\",\n );\n }\n return parseWebhookPayload(body);\n}\n\n/**\n * Parsea el cuerpo de una entrega **ya verificada** y devuelve los cambios.\n *\n * Sepárala de la verificación sólo si tienes una razón: llamarla a secas es aceptar cualquier\n * cosa que llegue a tu endpoint. Comprueba que sea un array porque el error natural —tratar el\n * cuerpo como un objeto— no da ningún síntoma hasta que se lee un campo que siempre es `undefined`.\n */\nexport function parseWebhookPayload(body: RawWebhookBody): WebhookChange[] {\n const text = toBuffer(body).toString(\"utf8\");\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch (cause) {\n throw new WebhookBodyError(`El cuerpo del webhook no es JSON válido: ${(cause as Error).message}`);\n }\n if (!Array.isArray(parsed)) {\n throw new WebhookBodyError(\n \"El cuerpo del webhook es un ARRAY de cambios, y ha llegado \" +\n `${parsed === null ? \"null\" : typeof parsed}. Recorre lo que llega.`,\n );\n }\n return parsed as WebhookChange[];\n}\n\n/** Lee una cabecera de un objeto plano o de un `Headers`, sin depender de mayúsculas. */\nfunction readHeader(headers: WebhookHeaders, name: string): string | undefined {\n if (headers && typeof (headers as { get?: unknown }).get === \"function\") {\n return (headers as { get(header: string): string | null }).get(name) ?? undefined;\n }\n const record = headers as Record<string, string | string[] | undefined>;\n const direct = record[name] ?? record[name.toLowerCase()];\n const found =\n direct ?? Object.entries(record).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];\n return Array.isArray(found) ? found[0] : found;\n}\n\n// -------------------------------------------------------------------------------------------\n// El middleware de Express\n// -------------------------------------------------------------------------------------------\n\n/**\n * Lo mínimo que este middleware necesita de una petición.\n *\n * Se declara aquí en vez de importar los tipos de Express **para no depender de Express**: el\n * paquete no tiene dependencias de runtime y no va a tener una de tipos. Un `Request` de verdad\n * cumple esto de sobra, así que el middleware encaja en `app.post(...)` sin un solo `cast`.\n */\nexport interface WebhookRequestLike {\n headers: Record<string, string | string[] | undefined>;\n /** Lo que haya dejado el body parser, si había alguno. */\n body?: unknown;\n}\n\n/** Lo mínimo que este middleware necesita de una respuesta. Vale un `Response` de Express y un `ServerResponse` pelado. */\nexport interface WebhookResponseLike {\n statusCode: number;\n end(chunk?: string): void;\n}\n\nexport interface PlanVortexWebhooksOptions {\n /** El `client_secret` de tu app. */\n secret: string;\n /**\n * Qué hacer con los cambios. Se espera a que termine antes de responder, así que si tu trabajo\n * es lento, encólalo aquí y vuelve: PlanVortex no reintenta una entrega que se cae.\n */\n onChanges: (changes: WebhookChange[], request: WebhookRequestLike) => void | Promise<void>;\n /** Para enterarte de una firma que no cuadra, que si no es silenciosa. */\n onError?: (error: Error, request: WebhookRequestLike) => void;\n /** Fuerza una de las dos cabeceras. Por defecto `sha256` si viene, y `sha1` si no. */\n algorithm?: WebhookAlgorithm;\n}\n\n/**\n * Middleware de Express que verifica la firma y te entrega los cambios.\n *\n * ```ts\n * app.post(\n * \"/webhooks/planvortex\",\n * planvortexWebhooks({\n * secret: process.env.PLANVORTEX_CLIENT_SECRET!,\n * onChanges: async (changes) => {\n * for (const change of changes) {\n * if (isCommentChange(change)) await moderate(change.commentObj);\n * }\n * },\n * }),\n * );\n * ```\n *\n * **No hace falta `express.raw()` delante**: si no encuentra cuerpo, lee el flujo él mismo. Lo que\n * no puede es arreglar un `express.json()` global, porque ése ya se bebió los bytes y los devuelve\n * convertidos en objeto — ahí responde 400 diciendo exactamente eso.\n *\n * Responde él: 200 si todo fue bien, 401 si la firma no cuadra o falta, 400 si el cuerpo no sirve,\n * y 500 si tu `onChanges` lanza (que además llega a `next`, para que lo vea tu manejador de\n * errores). Por eso va al final de la cadena y no lleva un `next()` de paso.\n */\nexport function planvortexWebhooks(\n options: PlanVortexWebhooksOptions,\n): (request: WebhookRequestLike, response: WebhookResponseLike, next: (error?: unknown) => void) => void {\n const { secret, onChanges, onError, algorithm } = options;\n\n return (request, response, next) => {\n void (async () => {\n let changes: WebhookChange[];\n try {\n const body = await resolveRawBody(request);\n changes = handleWebhookRequest({\n body,\n headers: request.headers,\n secret,\n ...(algorithm ? { algorithm } : {}),\n });\n } catch (error) {\n onError?.(error as Error, request);\n response.statusCode = error instanceof WebhookSignatureError ? 401 : 400;\n response.end();\n return;\n }\n\n try {\n await onChanges(changes, request);\n } catch (error) {\n onError?.(error as Error, request);\n response.statusCode = 500;\n response.end();\n next(error);\n return;\n }\n\n response.statusCode = 200;\n response.end();\n })();\n };\n}\n\n/**\n * Los bytes de la petición, vengan de donde vengan.\n *\n * Tres casos, y el tercero es el que más disgustos da: si `body` ya es un objeto, alguien parseó\n * el JSON antes y los bytes originales **ya no existen**. No se puede recuperar volviendo a\n * serializar —el orden de las claves y los espacios no tienen por qué coincidir— así que lo único\n * honesto es decirlo.\n */\nasync function resolveRawBody(request: WebhookRequestLike): Promise<RawWebhookBody> {\n const body = request.body;\n if (typeof body === \"string\" || Buffer.isBuffer(body) || body instanceof Uint8Array) {\n return body;\n }\n if (body === undefined || body === null) {\n const streamed = await readStream(request);\n if (streamed) {\n return streamed;\n }\n }\n return toBuffer(body as RawWebhookBody);\n}\n\nasync function readStream(request: WebhookRequestLike): Promise<Buffer | undefined> {\n const iterable = request as unknown as AsyncIterable<Uint8Array>;\n if (typeof iterable?.[Symbol.asyncIterator] !== \"function\") {\n return undefined;\n }\n const chunks: Uint8Array[] = [];\n for await (const chunk of iterable) {\n chunks.push(chunk);\n }\n return Buffer.concat(chunks);\n}\n"]}