idosell 0.4.43 → 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 +6 -0
- package/dist/app.d.ts +77 -69
- package/dist/webhooks.js +9 -2
- package/dist/webhooks.normalizer.js +1 -1
- package/package.json +1 -1
- package/tests/webhooks.test.ts +158 -0
package/changelog.md
CHANGED
|
@@ -5,6 +5,12 @@ 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
|
+
|
|
8
14
|
## [0.4.43] - 2025-06-07
|
|
9
15
|
### Changed
|
|
10
16
|
- Webhook now supports HMAC checking and groups of webhooks
|
package/dist/app.d.ts
CHANGED
|
@@ -7,109 +7,109 @@ import type {
|
|
|
7
7
|
} from "./responses.d.ts";
|
|
8
8
|
|
|
9
9
|
export interface ExecutableDumpParams {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
url: string;
|
|
11
|
+
method: string;
|
|
12
|
+
params: Record<string, any>;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export interface ExecutableOptions {
|
|
16
|
-
|
|
16
|
+
log?: boolean | ((obj: ExecutableDumpParams) => void),
|
|
17
17
|
dump?: boolean | ((obj: ExecutableDumpParams) => void),
|
|
18
18
|
logPage?: boolean | ((text: string) => void),
|
|
19
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
+
page: (pageNumber: number, pageSize?: number) => T
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
112
|
-
|
|
111
|
+
faultCode: number;
|
|
112
|
+
faultString: string;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
// ─── Final normalized shape ──────────────────────────────────────────────────
|
|
@@ -244,12 +244,20 @@ export type DispatchResult =
|
|
|
244
244
|
| { matched: false; eventType: string }
|
|
245
245
|
| { matched: false; eventType: null; reason: "validation_failed" };
|
|
246
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
|
+
|
|
247
254
|
export declare class WebhookChain {
|
|
248
255
|
validateHeaders(validator: HeaderValidator): this;
|
|
249
256
|
validateSignature(hmacKey: string): this;
|
|
250
257
|
on<E extends WebhookEventType>(eventType: E, handler: WebhookHandler<E>): this;
|
|
251
258
|
on<O extends WebhookObjectType>(objectType: O, handler: WebhookObjectHandler<O>): this;
|
|
252
259
|
handle(req: import("node:http").IncomingMessage | Request): Promise<DispatchResult>;
|
|
260
|
+
handleRaw(req: RawWebhookInput): Promise<DispatchResult>;
|
|
253
261
|
}
|
|
254
262
|
|
|
255
263
|
export type Webhooks = {
|
|
@@ -259,4 +267,4 @@ export type Webhooks = {
|
|
|
259
267
|
on<O extends WebhookObjectType>(objectType: O, handler: WebhookObjectHandler<O>): WebhookChain;
|
|
260
268
|
};
|
|
261
269
|
|
|
262
|
-
export {};
|
|
270
|
+
export { };
|
package/dist/webhooks.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
3
|
import { createHmac } from "node:crypto";
|
|
4
4
|
// ─── Runtime constants ────────────────────────────────────────────────────────
|
|
5
5
|
export const WEBHOOK_OBJECT_TYPE = {
|
|
@@ -106,6 +106,14 @@ export class WebhookChain {
|
|
|
106
106
|
}
|
|
107
107
|
async handle(req) {
|
|
108
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) {
|
|
109
117
|
if (this.validator !== null) {
|
|
110
118
|
let valid = false;
|
|
111
119
|
try {
|
|
@@ -118,7 +126,6 @@ export class WebhookChain {
|
|
|
118
126
|
return { matched: false, eventType: null, reason: "validation_failed" };
|
|
119
127
|
}
|
|
120
128
|
}
|
|
121
|
-
// Validate signature if a key was provided
|
|
122
129
|
if (this.hmacKey) {
|
|
123
130
|
const computed = createHmac("sha256", this.hmacKey).update(rawBody).digest("hex");
|
|
124
131
|
if (computed !== headers.signature) {
|
package/package.json
CHANGED
|
@@ -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
|
+
});
|