idosell 0.4.41 → 0.4.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.45] - 2025-06-07
9
+ ### Added
10
+ - Added handleRaw to pass raw data instead of request
11
+ - Added test to webhooks
12
+
13
+
14
+ ## [0.4.43] - 2025-06-07
15
+ ### Changed
16
+ - Webhook now supports HMAC checking and groups of webhooks
17
+ - Added raw data to webhook headers and handlers
18
+
19
+
8
20
  ## [0.4.41] - 2025-06-07
9
21
  ### Added
10
22
  - Added webook support
package/dist/app.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type {
2
- GetProductsResponse,
2
+ SearchProductsResponse,
3
3
  GetRmaResponse,
4
4
  GetReturnsResponse,
5
5
  GetOrdersResponse,
@@ -7,109 +7,109 @@ import type {
7
7
  } from "./responses.d.ts";
8
8
 
9
9
  export interface ExecutableDumpParams {
10
- url: string;
11
- method: string;
12
- params: Record<string,any>;
10
+ url: string;
11
+ method: string;
12
+ params: Record<string, any>;
13
13
  }
14
14
 
15
15
  export interface ExecutableOptions {
16
- log?: boolean | ((obj: ExecutableDumpParams) => void),
16
+ log?: boolean | ((obj: ExecutableDumpParams) => void),
17
17
  dump?: boolean | ((obj: ExecutableDumpParams) => void),
18
18
  logPage?: boolean | ((text: string) => void),
19
- skipCheck?: boolean
19
+ skipCheck?: boolean
20
20
  }
21
21
 
22
22
  export type DateLike = string | number | Date;
23
23
 
24
- export type JSObject = Record<string,any>;
24
+ export type JSObject = Record<string, any>;
25
25
 
26
26
  export type RequestProxyObject = {
27
- auth: {
28
- url: string,
29
- apiKey: string,
30
- version: number|string
31
- },
32
- params: Record<string, any>,
27
+ auth: {
28
+ url: string,
29
+ apiKey: string,
30
+ version: number | string
31
+ },
32
+ params: Record<string, any>,
33
33
  }
34
34
 
35
35
  export type RequirementType = {
36
- any: string[]|true
37
- } | string | ((arg: RequestProxyObject | Record<string,any>) => string|false);
36
+ any: string[] | true
37
+ } | string | ((arg: RequestProxyObject | Record<string, any>) => string | false);
38
38
 
39
39
  export type GatewayRequestProxyObject = {
40
- gate: {
41
- method: 'get'|'post'|'put'|'delete',
42
- node: string
43
- },
44
- appendable?: {
45
- index: number,
46
- arrayNode: string,
47
- except: string[]
48
- },
49
- custom?: Record<string,(..._: any) => false|Record<string,any>>,
50
- snakeCase?: boolean,
51
- paginationObject?: boolean,
52
- next?: boolean,
53
- rootparams?: string|boolean,
54
- arrays?: string[],
55
- req?: RequirementType[],
56
- n?: Record<string,number>
40
+ gate: {
41
+ method: 'get' | 'post' | 'put' | 'delete',
42
+ node: string
43
+ },
44
+ appendable?: {
45
+ index: number,
46
+ arrayNode: string,
47
+ except: string[]
48
+ },
49
+ custom?: Record<string, (..._: any) => false | Record<string, any>>,
50
+ snakeCase?: boolean,
51
+ paginationObject?: boolean,
52
+ next?: boolean,
53
+ rootparams?: string | boolean,
54
+ arrays?: string[],
55
+ req?: RequirementType[],
56
+ n?: Record<string, number>
57
57
  } & RequestProxyObject;
58
58
 
59
59
  export interface Gateway<R = JSObject, P = JSObject> {
60
- /**
61
- * Executes the query to designated API endpoint
62
- * @param options Use options: log - to console log params, url and method, logPage - to console log current page in a loop
63
- * @returns Idosell response
64
- */
65
- exec: (options?: ExecutableOptions) => Promise<R>,
66
-
67
- /**
68
- * @returns Object with currently mapped parameters
69
- */
70
- getParams: () => P,
71
-
72
- /**
73
- * @description Set object as params
74
- */
75
- setParams: (params: P) => this
76
-
77
- /**
78
- * @description Checks if minimal parameters are provided. If not, throws an error.
79
- */
80
- checkParams: () => void
60
+ /**
61
+ * Executes the query to designated API endpoint
62
+ * @param options Use options: log - to console log params, url and method, logPage - to console log current page in a loop
63
+ * @returns Idosell response
64
+ */
65
+ exec: (options?: ExecutableOptions) => Promise<R>,
66
+
67
+ /**
68
+ * @returns Object with currently mapped parameters
69
+ */
70
+ getParams: () => P,
71
+
72
+ /**
73
+ * @description Set object as params
74
+ */
75
+ setParams: (params: P) => this
76
+
77
+ /**
78
+ * @description Checks if minimal parameters are provided. If not, throws an error.
79
+ */
80
+ checkParams: () => void
81
81
  }
82
82
 
83
- export interface PagableGateway<T,R = JSObject, P = JSObject> extends Gateway<R, P> {
84
- /**
83
+ export interface PagableGateway<T, R = JSObject, P = JSObject> extends Gateway<R, P> {
84
+ /**
85
85
  * @returns number of items i.e. products, orders, documents, etc.
86
86
  */
87
- count: () => Promise<number>,
87
+ count: () => Promise<number>,
88
88
 
89
- /**
89
+ /**
90
90
  * Allows to change offset and number of records returned
91
91
  * @param pageNumber - The page number to navigate to.
92
92
  * @param pageSize - The size of page
93
93
  * @returns The updated instance for method chaining.
94
94
  */
95
- page: (pageNumber: number, pageSize?: number) => T
95
+ page: (pageNumber: number, pageSize?: number) => T
96
96
 
97
- /**
98
- * @returns If completed request has more pages
99
- */
100
- hasNext: () => boolean
97
+ /**
98
+ * @returns If completed request has more pages
99
+ */
100
+ hasNext: () => boolean
101
101
  }
102
102
 
103
- export interface AppendableGateway<T,R = JSObject, P = JSObject> extends Gateway<R, P> {
104
- /**
105
- * Start creating next item in list
106
- */
107
- append: () => T
103
+ export interface AppendableGateway<T, R = JSObject, P = JSObject> extends Gateway<R, P> {
104
+ /**
105
+ * Start creating next item in list
106
+ */
107
+ append: () => T
108
108
  }
109
109
 
110
110
  export interface IdosellErrorFaultStructure {
111
- faultCode: number;
112
- faultString: string;
111
+ faultCode: number;
112
+ faultString: string;
113
113
  }
114
114
 
115
115
  // ─── Final normalized shape ──────────────────────────────────────────────────
@@ -117,6 +117,7 @@ export interface IdosellErrorFaultStructure {
117
117
  export interface NormalizedIaiRequest<T = unknown> {
118
118
  headers: IaiWebhookHeaders;
119
119
  body: T;
120
+ rawBody: string
120
121
  }
121
122
 
122
123
  export declare const WEBHOOK_OBJECT_TYPE: {
@@ -162,9 +163,9 @@ export declare const WEBHOOK_EVENT_TYPE: {
162
163
  export type WebhookObjectType = typeof WEBHOOK_OBJECT_TYPE[keyof typeof WEBHOOK_OBJECT_TYPE];
163
164
  export type WebhookEventType = typeof WEBHOOK_EVENT_TYPE[keyof typeof WEBHOOK_EVENT_TYPE];
164
165
 
165
- interface ObjectBodyMap {
166
+ export interface ObjectBodyMap {
166
167
  "client": GetClientsResponse;
167
- "product": GetProductsResponse;
168
+ "product": SearchProductsResponse;
168
169
  "order": GetOrdersResponse;
169
170
  "return": GetReturnsResponse;
170
171
  "rma": GetRmaResponse;
@@ -214,16 +215,27 @@ export interface IaiWebhookHeaders {
214
215
  panelId: number;
215
216
  signature: string;
216
217
  webhookTime: Date;
218
+ raw: Record<string, string>
217
219
  }
218
220
 
219
221
  export interface WebhookContext<E extends WebhookEventType> {
220
222
  headers: IaiWebhookHeaders;
221
223
  body: ObjectBodyMap[EventObjectMap[E]];
224
+ rawBody: string;
225
+ }
226
+
227
+ export interface WebhookObjectContext<O extends WebhookObjectType> {
228
+ headers: IaiWebhookHeaders;
229
+ body: ObjectBodyMap[O];
230
+ rawBody: string;
222
231
  }
223
232
 
224
233
  export type WebhookHandler<E extends WebhookEventType> =
225
234
  (ctx: WebhookContext<E>) => Promise<void> | void;
226
235
 
236
+ export type WebhookObjectHandler<O extends WebhookObjectType> =
237
+ (ctx: WebhookObjectContext<O>) => Promise<void> | void;
238
+
227
239
  export type HeaderValidator =
228
240
  (headers: IaiWebhookHeaders) => Promise<boolean> | boolean;
229
241
 
@@ -232,15 +244,27 @@ export type DispatchResult =
232
244
  | { matched: false; eventType: string }
233
245
  | { matched: false; eventType: null; reason: "validation_failed" };
234
246
 
247
+
248
+ export interface RawWebhookInput {
249
+ headers: Record<string, string>;
250
+ rawBody: string; // always string — pre-parse
251
+ body?: Record<string, unknown>; // optional — caller may pre-parse
252
+ }
253
+
235
254
  export declare class WebhookChain {
236
255
  validateHeaders(validator: HeaderValidator): this;
256
+ validateSignature(hmacKey: string): this;
237
257
  on<E extends WebhookEventType>(eventType: E, handler: WebhookHandler<E>): this;
258
+ on<O extends WebhookObjectType>(objectType: O, handler: WebhookObjectHandler<O>): this;
238
259
  handle(req: import("node:http").IncomingMessage | Request): Promise<DispatchResult>;
260
+ handleRaw(req: RawWebhookInput): Promise<DispatchResult>;
239
261
  }
240
262
 
241
263
  export type Webhooks = {
242
264
  validateHeaders(validator: HeaderValidator): WebhookChain;
265
+ validateSignature(hmacKey: string): WebhookChain;
243
266
  on<E extends WebhookEventType>(eventType: E, handler: WebhookHandler<E>): WebhookChain;
267
+ on<O extends WebhookObjectType>(objectType: O, handler: WebhookObjectHandler<O>): WebhookChain;
244
268
  };
245
269
 
246
- export {};
270
+ export { };
package/dist/webhooks.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable no-unused-vars */
2
- import { normalizeIaiRequest, WebhookValidationError } from "./webhooks.normalizer.js";
2
+ import { extractHeaders, normalizeIaiRequest, WebhookValidationError } from "./webhooks.normalizer.js";
3
+ import { createHmac } from "node:crypto";
3
4
  // ─── Runtime constants ────────────────────────────────────────────────────────
4
5
  export const WEBHOOK_OBJECT_TYPE = {
5
6
  CLIENT: "client",
@@ -74,19 +75,45 @@ const EVENT_OBJECT_MAP = {
74
75
  export class WebhookChain {
75
76
  slots = [];
76
77
  validator = null;
78
+ hmacKey;
77
79
  validateHeaders(validator) {
78
80
  this.validator = validator;
79
81
  return this;
80
82
  }
81
- on(eventType, handler) {
82
- this.slots.push({
83
- eventType,
84
- fn: handler,
85
- });
83
+ /**
84
+ * Validate the request signature using HMAC SHA256.
85
+ * @param hmacKey Secret key used to compute the HMAC.
86
+ * @throws WebhookValidationError if the computed signature does not match the header.
87
+ */
88
+ validateSignature(hmacKey) {
89
+ this.hmacKey = hmacKey;
90
+ return this;
91
+ }
92
+ on(eventTypeOrObject, handler) {
93
+ const isObjectType = Object.values(WEBHOOK_OBJECT_TYPE).includes(eventTypeOrObject);
94
+ if (isObjectType) {
95
+ const matchedEvents = Object.entries(EVENT_OBJECT_MAP)
96
+ .filter(([, obj]) => obj === eventTypeOrObject)
97
+ .map(([evt]) => evt);
98
+ for (const evt of matchedEvents) {
99
+ this.slots.push({ eventType: evt, fn: handler });
100
+ }
101
+ }
102
+ else {
103
+ this.slots.push({ eventType: eventTypeOrObject, fn: handler });
104
+ }
86
105
  return this;
87
106
  }
88
107
  async handle(req) {
89
- const { headers, body } = await normalizeIaiRequest(req);
108
+ const { headers, body, rawBody } = await normalizeIaiRequest(req);
109
+ return this.dispatch(headers, body, rawBody);
110
+ }
111
+ async handleRaw({ headers: rawHeaders, rawBody, body }) {
112
+ const headers = extractHeaders(rawHeaders);
113
+ const parsedBody = body ?? JSON.parse(rawBody);
114
+ return this.dispatch(headers, parsedBody, rawBody);
115
+ }
116
+ async dispatch(headers, body, rawBody) {
90
117
  if (this.validator !== null) {
91
118
  let valid = false;
92
119
  try {
@@ -99,6 +126,12 @@ export class WebhookChain {
99
126
  return { matched: false, eventType: null, reason: "validation_failed" };
100
127
  }
101
128
  }
129
+ if (this.hmacKey) {
130
+ const computed = createHmac("sha256", this.hmacKey).update(rawBody).digest("hex");
131
+ if (computed !== headers.signature) {
132
+ throw new WebhookValidationError("Invalid webhook signature", "x-iai-signature");
133
+ }
134
+ }
102
135
  const incomingEvent = headers.eventType;
103
136
  const expectedObject = EVENT_OBJECT_MAP[incomingEvent];
104
137
  if (expectedObject !== undefined && headers.objectType !== expectedObject) {
@@ -108,7 +141,7 @@ export class WebhookChain {
108
141
  if (!slot) {
109
142
  return { matched: false, eventType: incomingEvent };
110
143
  }
111
- await slot.fn({ headers, body });
144
+ await slot.fn({ headers, body, rawBody });
112
145
  return { matched: true, eventType: slot.eventType };
113
146
  }
114
147
  }
@@ -117,8 +150,11 @@ export const webhooks = {
117
150
  validateHeaders(validator) {
118
151
  return new WebhookChain().validateHeaders(validator);
119
152
  },
120
- on(eventType, handler) {
121
- return new WebhookChain().on(eventType, handler);
153
+ validateSignature(hmacKey) {
154
+ return new WebhookChain().validateSignature(hmacKey);
122
155
  },
156
+ on: ((eventTypeOrObject, handler) => {
157
+ return new WebhookChain().on(eventTypeOrObject, handler);
158
+ }),
123
159
  };
124
160
  export default webhooks;
@@ -39,7 +39,7 @@ function parseAuthHeader(headers) {
39
39
  }
40
40
  return token;
41
41
  }
42
- function extractHeaders(raw) {
42
+ export function extractHeaders(raw) {
43
43
  return {
44
44
  token: parseAuthHeader(raw),
45
45
  apiVersion: parseIntHeader(raw, "x-iai-api-version"),
@@ -50,6 +50,7 @@ function extractHeaders(raw) {
50
50
  panelId: parseIntHeader(raw, "x-iai-panel-id"),
51
51
  signature: requireHeader(raw, "x-iai-signature"),
52
52
  webhookTime: parseDateHeader(raw, "x-iai-webhook-time"),
53
+ raw
53
54
  };
54
55
  }
55
56
  // ─── Type guard ──────────────────────────────────────────────────────────────
@@ -136,5 +137,5 @@ export async function normalizeIaiRequest(req) {
136
137
  throw new WebhookValidationError("Request body must be a JSON object", "body");
137
138
  }
138
139
  const headers = extractHeaders(flatHeaders);
139
- return { headers, body };
140
+ return { headers, body, rawBody };
140
141
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idosell",
3
- "version": "0.4.41",
3
+ "version": "0.4.45",
4
4
  "description": "Idosell 3 REST connector",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/gateways.d.ts",
@@ -0,0 +1,158 @@
1
+ // vitest tests for the webhook dispatcher
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { createHmac } from 'node:crypto';
4
+ import { webhooks } from '../dist/webhooks';
5
+ import { normalizeIaiRequest } from '../dist/webhooks.normalizer';
6
+
7
+ const API_VERSION = '8';
8
+ const SIGNATURE_KEY = 'secret-hmac-key';
9
+ const TOKEN = 'authentication-bearer-token'
10
+
11
+ // Helper to build a Request with appropriate headers and body
12
+ function buildRequest({
13
+ body,
14
+ eventType = 'productUpdated',
15
+ objectType = 'product',
16
+ }: {
17
+ body: unknown;
18
+ eventType?: string;
19
+ objectType?: string;
20
+ }) {
21
+ const rawBody = JSON.stringify(body);
22
+ const signature = createHmac('sha256', SIGNATURE_KEY).update(rawBody).digest('hex');
23
+ const headers = new Headers({
24
+ 'Content-Type': 'application/json',
25
+ 'x-iai-api-version': API_VERSION,
26
+ 'x-iai-event-time': new Date().toISOString(),
27
+ 'x-iai-event-type': eventType,
28
+ 'x-iai-event-uid': 'uid-123',
29
+ 'x-iai-object-type': objectType,
30
+ 'x-iai-panel-id': '1',
31
+ 'x-iai-signature': signature,
32
+ 'x-iai-webhook-time': new Date().toISOString(),
33
+ 'authorization': `Bearer ${TOKEN}`,
34
+ });
35
+ return new Request('https://api.example.com/webhook', {
36
+ method: 'POST',
37
+ headers,
38
+ body: rawBody,
39
+ });
40
+ }
41
+
42
+ describe('WebhookChain dispatcher', () => {
43
+ let mockHandler: ReturnType<typeof vi.fn>;
44
+
45
+ beforeEach(() => {
46
+ mockHandler = vi.fn();
47
+ });
48
+
49
+ it('routes a matching event to the registered handler', async () => {
50
+ const requestBody = {
51
+ results: [{ productDisplayedCode: 'ABC123' }],
52
+ };
53
+ const req = buildRequest({ body: requestBody });
54
+
55
+ const chain = webhooks
56
+ .validateHeaders((h) => h.token === TOKEN)
57
+ .validateSignature(SIGNATURE_KEY)
58
+ .on('productUpdated', mockHandler);
59
+
60
+ const result = await chain.handle(req);
61
+
62
+ expect(result).toEqual({ matched: true, eventType: 'productUpdated' });
63
+ expect(mockHandler).toHaveBeenCalledTimes(1);
64
+ const ctx = mockHandler.mock.calls[0][0];
65
+ expect(ctx.body).toEqual(requestBody);
66
+ expect(ctx.headers.eventType).toBe('productUpdated');
67
+ expect(ctx.headers.objectType).toBe('product');
68
+ });
69
+
70
+ it('returns unmatched when no handler is registered for the event', async () => {
71
+ const requestBody = { foo: 'bar' };
72
+ const req = buildRequest({ body: requestBody, eventType: 'orderCreated', objectType: 'order' });
73
+
74
+ const chain = webhooks
75
+ .validateHeaders((h) => h.token === TOKEN)
76
+ .validateSignature(SIGNATURE_KEY);
77
+
78
+ const result = await chain.handle(req);
79
+ expect(result).toEqual({ matched: false, eventType: 'orderCreated' });
80
+ expect(mockHandler).not.toHaveBeenCalled();
81
+ });
82
+
83
+ it('fails validateHeaders when token mismatched', async () => {
84
+ const requestBody = { foo: 'bar' };
85
+ const req = buildRequest({ body: requestBody });
86
+ const chain = webhooks
87
+ .validateHeaders(() => false)
88
+ .validateSignature(SIGNATURE_KEY)
89
+ .on('productUpdated', mockHandler);
90
+ const result = await chain.handle(req);
91
+ expect(result).toEqual({ matched: false, eventType: null, reason: 'validation_failed' });
92
+ });
93
+
94
+ it('fails validateSignature with wrong key', async () => {
95
+ const requestBody = { foo: 'bar' };
96
+ const req = buildRequest({ body: requestBody });
97
+ const chain = webhooks
98
+ .validateHeaders((h) => h.token === TOKEN)
99
+ .validateSignature('wrong-key')
100
+ .on('productUpdated', mockHandler);
101
+ await expect(chain.handle(req)).rejects.toThrow('Invalid webhook signature');
102
+ });
103
+
104
+ it('registers handler for object type "order" and matches order events', async () => {
105
+ const requestBody = { foo: 'bar' };
106
+ const req = buildRequest({ body: requestBody, eventType: 'orderCreated', objectType: 'order' });
107
+ const chain = webhooks
108
+ .validateHeaders((h) => h.token === TOKEN)
109
+ .validateSignature(SIGNATURE_KEY)
110
+ .on('order', mockHandler);
111
+ await expect(chain.handle(req)).resolves.not.toThrow();
112
+ });
113
+
114
+ it('normalizes request and verifies header shape', async () => {
115
+ const requestBody = { foo: 'bar' };
116
+ const req = buildRequest({ body: requestBody });
117
+ const { headers } = await normalizeIaiRequest(req);
118
+ // Basic shape checks
119
+ expect(headers).toMatchObject({
120
+ token: TOKEN,
121
+ apiVersion: Number(API_VERSION),
122
+ eventType: 'productUpdated',
123
+ objectType: 'product',
124
+ panelId: 1,
125
+ signature: expect.any(String),
126
+ });
127
+ // Type checks for dates
128
+ expect(headers.eventTime).toBeInstanceOf(Date);
129
+ expect(headers.webhookTime).toBeInstanceOf(Date);
130
+ });
131
+
132
+ it('handleRaw processes raw webhook input correctly', async () => {
133
+ const requestBody = { foo: 'bar' };
134
+ const rawBody = JSON.stringify(requestBody);
135
+ const rawHeaders = {
136
+ 'content-type': 'application/json',
137
+ 'x-iai-api-version': API_VERSION,
138
+ 'x-iai-event-time': new Date().toISOString(),
139
+ 'x-iai-event-type': 'productUpdated',
140
+ 'x-iai-event-uid': 'uid-123',
141
+ 'x-iai-object-type': 'product',
142
+ 'x-iai-panel-id': '1',
143
+ 'x-iai-signature': createHmac('sha256', SIGNATURE_KEY).update(rawBody).digest('hex'),
144
+ 'x-iai-webhook-time': new Date().toISOString(),
145
+ 'authorization': `Bearer ${TOKEN}`,
146
+ } as Record<string, string>;
147
+ const chain = webhooks
148
+ .validateHeaders((h) => h.token === TOKEN)
149
+ .validateSignature(SIGNATURE_KEY)
150
+ .on('productUpdated', mockHandler);
151
+ const result = await chain.handleRaw({ headers: rawHeaders, rawBody, body: requestBody });
152
+ expect(result).toEqual({ matched: true, eventType: 'productUpdated' });
153
+ expect(mockHandler).toHaveBeenCalledTimes(1);
154
+ const ctx = mockHandler.mock.calls[0][0];
155
+ expect(ctx.body).toEqual(requestBody);
156
+ expect(ctx.headers.objectType).toBe('product');
157
+ });
158
+ });