qubesync 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ First version: webhooks only.
6
+
7
+ - `verify(rawBody, header, { secrets, tolerance = 300, now })` checks the `X-QUBE-Signature`
8
+ header (HMAC-SHA256 of `"<t>." + raw body`, constant-time compare, one or more secrets while
9
+ one rotates) and returns a typed event, or throws `SignatureError` with `reason` `missing`,
10
+ `malformed`, `stale` or `mismatch`.
11
+ - `parse(rawBody)` builds the typed event without verifying.
12
+ - Events: `RequestEvent`, `WorkflowRunEvent`, `NotificationEvent`, `ConnectionEvent`,
13
+ `NameIndexEvent` and `UnknownEvent`, each with `type`, `event`, `timestamp`, `appId`,
14
+ `sandbox`, `body`, `revision`, `reference` and `dedupeKey`; `newerThan(storedRevision)` on
15
+ workflow runs and connections.
16
+ - CommonJS and ESM entry points, TypeScript declarations, zero dependencies, Node 16.17 and later.
17
+ - Tested against the server's own test vectors (`test/fixtures/test_vectors.json`), and CI checks
18
+ that copy against https://qubesync.com/webhooks/test_vectors.json.
19
+ - A `v*` tag publishes to npm (`release.yml`, with the `NPM_TOKEN` secret).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 QuBe Sync
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,142 @@
1
- # Temporary Holding Version
1
+ # qubesync (Node)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Verify and parse [QuBe Sync](https://qubesync.com) webhooks: QuickBooks Desktop through
4
+ qubesync.com. Plain JavaScript, zero dependencies, Node 16.17 and later, CommonJS and ESM,
5
+ TypeScript declarations included.
6
+
7
+ ```bash
8
+ npm install qubesync
9
+ ```
10
+
11
+ ## Webhooks
12
+
13
+ Every delivery carries an `X-QUBE-Signature` header: `t=<unix seconds>,v1=<hex>[,v1=<hex>...]`,
14
+ one `v1` per active secret while you rotate. `verify` checks it against the **raw** body, then
15
+ parses the body into a typed event:
16
+
17
+ ```js
18
+ const { verify, SignatureError } = require("qubesync"); // or: import { verify, SignatureError } from "qubesync";
19
+
20
+ const SECRETS = [process.env.QUBE_WEBHOOK_SECRET]; // add the new secret here while you rotate
21
+
22
+ function handleDelivery(rawBody, signatureHeader) {
23
+ let event;
24
+ try {
25
+ event = verify(rawBody, signatureHeader, { secrets: SECRETS });
26
+ } catch (error) {
27
+ if (error instanceof SignatureError) return 401; // error.reason: missing | malformed | stale | mismatch
28
+ throw error;
29
+ }
30
+
31
+ switch (event.type) {
32
+ case "request":
33
+ // A queued request's answer. Dedupe on (page_request_id, state): event.dedupeKey.
34
+ // A paginated query is done when you hold pages 1..N for the page_request_id.
35
+ if (alreadyProcessed(event.dedupeKey)) return 200;
36
+ saveAnswer(event.pageRequestId, event.page, event.state, event.responseJson, event.error);
37
+ break;
38
+
39
+ case "workflow_run":
40
+ // awaiting_input, completed, failed or cancelled. Deliveries can arrive out of order:
41
+ // apply one only when it's newer than the revision you stored for the run.
42
+ if (!event.newerThan(storedRunRevision(event.runId))) return 200;
43
+ saveRun(event.runId, event.reference, event.revision, event.runState, event.output, event.prompt);
44
+ break;
45
+
46
+ case "notification":
47
+ // Data a run sends along the way. Dedupe on event.dedupeKey (the delivery id);
48
+ // never skip one by revision.
49
+ if (alreadyProcessed(event.dedupeKey)) return 200;
50
+ saveNotification(event.runId, event.name, event.seq, event.data);
51
+ break;
52
+
53
+ case "connection":
54
+ // connected, setup_cancelled, stalled, resumed. Dedupe on the delivery id and apply
55
+ // only when newer than the connection's stored revision.
56
+ if (alreadyProcessed(event.dedupeKey) || !event.newerThan(storedConnectionRevision(event.connectionId))) return 200;
57
+ saveConnection(event.connectionId, event.event, event.revision, event.stallReason);
58
+ break;
59
+
60
+ case "name_index":
61
+ saveNameIndex(event.connectionId, event.nameIndex);
62
+ break;
63
+
64
+ default:
65
+ // "unknown": an event newer than this library. Accept it; event.body has everything.
66
+ break;
67
+ }
68
+ return 200;
69
+ }
70
+ ```
71
+
72
+ `verify(rawBody, header, { secrets, tolerance = 300, now })`:
73
+
74
+ - `rawBody` is the body exactly as received, as a `Buffer` or string (a string is signed as its
75
+ UTF-8 bytes). A parsed object throws a `TypeError`: re-serialized JSON never matches.
76
+ - `header` is the `X-QUBE-Signature` value. Node lowercases header names, so read
77
+ `req.headers["x-qube-signature"]`.
78
+ - `secrets` is one secret or an array of them. An empty or missing list throws a `TypeError`. The
79
+ library never reads the environment.
80
+ - `tolerance` is how many seconds the timestamp may be off either way. `now` is unix seconds or a
81
+ `Date` and defaults to the current time.
82
+ - On failure it throws `SignatureError`, whose `reason` is `missing`, `malformed`, `stale` or
83
+ `mismatch`, checked in that order. Its message is safe to log: it never holds the secret, the
84
+ signatures or the body.
85
+
86
+ `parse(rawBody)` builds the same typed event without verifying, for tests and for code that
87
+ verified elsewhere.
88
+
89
+ ### Events
90
+
91
+ Every event has `type`, `event` (or `null` for a request's answer), `timestamp`, `appId`,
92
+ `sandbox`, `revision`, `reference`, `dedupeKey` and `body` (the whole parsed body). Missing keys
93
+ read as `null`; nothing throws.
94
+
95
+ | `type` | when | also |
96
+ | --- | --- | --- |
97
+ | `request` | no `event`, has `page_request_id` | `id`, `pageRequestId`, `state`, `page`, `iterationState`, `responseJson`, `error`; `dedupeKey` is `"<page_request_id>:<state>"` |
98
+ | `workflow_run` | `workflow_run.awaiting_input`, `.completed`, `.failed`, `.cancelled` | `runId`, `reference`, `revision`, `runState`, `workflow`, `connectionId`, `prompt`, `outcome`, `output`, `error`, `summary`, `cancelReason`, `links`, `newerThan(stored)` |
99
+ | `notification` | `workflow_run.notification` | `runId`, `reference`, `revision`, `deliveryId`, `seq`, `name`, `state`, `from`, `data`; `dedupeKey` is the delivery id; no `newerThan` |
100
+ | `connection` | `connection.*` | `deliveryId`, `revision`, `connection`, `connectionId`, `stallReason`, `newerThan(stored)`; `dedupeKey` is the delivery id |
101
+ | `name_index` | `name_index.*` | `connectionId`, `nameIndex` |
102
+ | `unknown` | anything else | the common fields only |
103
+
104
+ `newerThan(stored)` is `true` when `stored` is `null` or `undefined`, else `revision > stored`.
105
+
106
+ ### Express
107
+
108
+ Verify before any JSON body parser touches the request: mount `express.raw` on the webhook route
109
+ so `req.body` is the raw `Buffer`.
110
+
111
+ ```js
112
+ const express = require("express");
113
+ const { verify, SignatureError } = require("qubesync");
114
+
115
+ const app = express();
116
+
117
+ app.post("/webhooks/qube", express.raw({ type: "application/json" }), (req, res) => {
118
+ let event;
119
+ try {
120
+ event = verify(req.body, req.get("X-QUBE-Signature"), { secrets: [process.env.QUBE_WEBHOOK_SECRET] });
121
+ } catch (error) {
122
+ if (error instanceof SignatureError) return res.sendStatus(401);
123
+ throw error;
124
+ }
125
+ // Route by event.type as above, then answer quickly.
126
+ res.sendStatus(200);
127
+ });
128
+ ```
129
+
130
+ ## Development
131
+
132
+ ```sh
133
+ npm test # node:test, no dependencies
134
+ npm run test:types # tsc --strict against test/types.ts (fetches TypeScript with npx)
135
+ ```
136
+
137
+ `test/fixtures/test_vectors.json` comes from the QuBe Sync server (`mix webhook_vectors.gen`).
138
+ Copy a new version in; don't edit it.
139
+
140
+ ## License
141
+
142
+ MIT
package/index.d.ts ADDED
@@ -0,0 +1,157 @@
1
+ // Type definitions for qubesync (QuBe Sync webhooks).
2
+
3
+ export type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
4
+ export interface JsonObject {
5
+ [key: string]: JsonValue;
6
+ }
7
+
8
+ export declare const VERSION: string;
9
+ /** The header QuBe Sync signs deliveries in. Node lowercases it: `req.headers["x-qube-signature"]`. */
10
+ export declare const SIGNATURE_HEADER: "X-QUBE-Signature";
11
+ export declare const DEFAULT_TOLERANCE: 300;
12
+
13
+ export type SignatureErrorReason = "missing" | "malformed" | "stale" | "mismatch";
14
+ export declare const REASONS: readonly SignatureErrorReason[];
15
+
16
+ /** Thrown by `verify` when a delivery can't be trusted. Answer 401. The message is safe to log. */
17
+ export declare class SignatureError extends Error {
18
+ constructor(reason: SignatureErrorReason, message: string);
19
+ readonly name: "SignatureError";
20
+ readonly reason: SignatureErrorReason;
21
+ }
22
+
23
+ /** The raw request body exactly as received. A `Buffer` is a `Uint8Array`. */
24
+ export type RawBody = string | Uint8Array;
25
+
26
+ export interface VerifyOptions {
27
+ /** One secret, or several while one rotates. Required and non-empty. */
28
+ secrets: string | readonly string[];
29
+ /** Seconds the timestamp may be off either way. Default 300. */
30
+ tolerance?: number;
31
+ /** Unix seconds or a Date. Default: the current time. */
32
+ now?: number | Date;
33
+ }
34
+
35
+ export type EventType = "request" | "workflow_run" | "notification" | "connection" | "name_index" | "unknown";
36
+
37
+ export declare abstract class Event {
38
+ /** The whole parsed body. */
39
+ readonly body: JsonObject;
40
+ readonly type: EventType;
41
+ readonly event: string | null;
42
+ readonly timestamp: number | null;
43
+ readonly appId: string | null;
44
+ readonly sandbox: boolean | null;
45
+ readonly revision: number | null;
46
+ readonly reference: string | null;
47
+ readonly dedupeKey: string | null;
48
+ }
49
+
50
+ /** A queued request's answer: no `event` key, has `page_request_id`. */
51
+ export declare class RequestEvent extends Event {
52
+ readonly type: "request";
53
+ readonly revision: null;
54
+ readonly reference: null;
55
+ readonly id: string | null;
56
+ readonly pageRequestId: string | null;
57
+ readonly state: string | null;
58
+ readonly page: number | null;
59
+ readonly iterationState: string | null;
60
+ readonly responseJson: JsonValue;
61
+ readonly error: JsonValue;
62
+ /** `"<page_request_id>:<state>"`. */
63
+ readonly dedupeKey: string | null;
64
+ }
65
+
66
+ /** `workflow_run.awaiting_input`, `.completed`, `.failed` or `.cancelled`. */
67
+ export declare class WorkflowRunEvent extends Event {
68
+ readonly type: "workflow_run";
69
+ readonly event: string;
70
+ readonly runId: string | null;
71
+ readonly runState: string | null;
72
+ readonly workflow: string | null;
73
+ readonly connectionId: string | null;
74
+ readonly prompt: JsonObject | null;
75
+ readonly outcome: string | null;
76
+ readonly output: JsonValue;
77
+ readonly error: JsonValue;
78
+ readonly summary: JsonObject | null;
79
+ readonly cancelReason: string | null;
80
+ readonly links: JsonObject | null;
81
+ /** Always null: apply a run's webhook when `newerThan(storedRevision)`. */
82
+ readonly dedupeKey: null;
83
+ /** True when `storedRevision` is null/undefined, else `revision > storedRevision`. */
84
+ newerThan(storedRevision: number | null | undefined): boolean;
85
+ }
86
+
87
+ /** `workflow_run.notification`: data a run sends along the way. Never skipped by revision. */
88
+ export declare class NotificationEvent extends Event {
89
+ readonly type: "notification";
90
+ readonly event: "workflow_run.notification";
91
+ readonly runId: string | null;
92
+ readonly deliveryId: string | null;
93
+ readonly seq: number | null;
94
+ readonly name: string | null;
95
+ readonly state: string | null;
96
+ readonly from: JsonObject | null;
97
+ readonly data: JsonValue;
98
+ /** The `delivery_id`. */
99
+ readonly dedupeKey: string | null;
100
+ }
101
+
102
+ /** `connection.*`. */
103
+ export declare class ConnectionEvent extends Event {
104
+ readonly type: "connection";
105
+ readonly event: string;
106
+ readonly reference: null;
107
+ readonly deliveryId: string | null;
108
+ readonly connection: JsonObject | null;
109
+ /** `connection.id`. */
110
+ readonly connectionId: string | null;
111
+ readonly stallReason: string | null;
112
+ /** The `delivery_id`. */
113
+ readonly dedupeKey: string | null;
114
+ /** True when `storedRevision` is null/undefined, else `revision > storedRevision`. */
115
+ newerThan(storedRevision: number | null | undefined): boolean;
116
+ }
117
+
118
+ /** `name_index.*`. */
119
+ export declare class NameIndexEvent extends Event {
120
+ readonly type: "name_index";
121
+ readonly event: string;
122
+ readonly revision: null;
123
+ readonly reference: null;
124
+ readonly connectionId: string | null;
125
+ readonly nameIndex: JsonObject | null;
126
+ readonly dedupeKey: null;
127
+ }
128
+
129
+ /** Anything else, including events newer than this library. Never refused. */
130
+ export declare class UnknownEvent extends Event {
131
+ readonly type: "unknown";
132
+ readonly revision: null;
133
+ readonly reference: null;
134
+ readonly dedupeKey: null;
135
+ }
136
+
137
+ export type WebhookEvent =
138
+ | RequestEvent
139
+ | WorkflowRunEvent
140
+ | NotificationEvent
141
+ | ConnectionEvent
142
+ | NameIndexEvent
143
+ | UnknownEvent;
144
+
145
+ /**
146
+ * Verifies the X-QUBE-Signature header over the raw body, then parses it.
147
+ * Throws `SignatureError` (reason `missing`, `malformed`, `stale` or `mismatch`),
148
+ * or `TypeError` for a programming mistake (no secrets, a parsed body).
149
+ */
150
+ export declare function verify(
151
+ rawBody: RawBody,
152
+ header: string | readonly string[] | null | undefined,
153
+ options: VerifyOptions
154
+ ): WebhookEvent;
155
+
156
+ /** Parses a body into a typed event without verifying it. */
157
+ export declare function parse(rawBody: RawBody): WebhookEvent;
package/index.js ADDED
@@ -0,0 +1,405 @@
1
+ "use strict";
2
+
3
+ // QuBe Sync webhooks: verify a delivery's X-QUBE-Signature header, then parse
4
+ // the body into a typed event. Framework-free, zero dependencies.
5
+
6
+ const crypto = require("crypto");
7
+
8
+ const VERSION = "0.1.0";
9
+ const SIGNATURE_HEADER = "X-QUBE-Signature";
10
+ const DEFAULT_TOLERANCE = 300;
11
+ const REASONS = Object.freeze(["missing", "malformed", "stale", "mismatch"]);
12
+
13
+ const WORKFLOW_RUN_EVENTS = Object.freeze([
14
+ "workflow_run.awaiting_input",
15
+ "workflow_run.completed",
16
+ "workflow_run.failed",
17
+ "workflow_run.cancelled",
18
+ ]);
19
+ const NOTIFICATION_EVENT = "workflow_run.notification";
20
+
21
+ class SignatureError extends Error {
22
+ constructor(reason, message) {
23
+ super(message);
24
+ this.name = "SignatureError";
25
+ this.reason = reason;
26
+ }
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Events
31
+
32
+ function own(object, key) {
33
+ if (object === null || typeof object !== "object" || Array.isArray(object)) return null;
34
+ if (!Object.prototype.hasOwnProperty.call(object, key)) return null;
35
+ const value = object[key];
36
+ return value === undefined ? null : value;
37
+ }
38
+
39
+ class Event {
40
+ constructor(body) {
41
+ Object.defineProperty(this, "body", { value: body, enumerable: true });
42
+ }
43
+
44
+ get type() {
45
+ return "unknown";
46
+ }
47
+ get event() {
48
+ const event = own(this.body, "event");
49
+ return typeof event === "string" ? event : null;
50
+ }
51
+ get timestamp() {
52
+ return own(this.body, "timestamp");
53
+ }
54
+ get appId() {
55
+ return own(this.body, "app_id");
56
+ }
57
+ get sandbox() {
58
+ return own(this.body, "sandbox");
59
+ }
60
+ get revision() {
61
+ return null;
62
+ }
63
+ get reference() {
64
+ return null;
65
+ }
66
+ get dedupeKey() {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ class UnknownEvent extends Event {}
72
+
73
+ class RequestEvent extends Event {
74
+ get type() {
75
+ return "request";
76
+ }
77
+ get id() {
78
+ return own(this.body, "id");
79
+ }
80
+ get pageRequestId() {
81
+ return own(this.body, "page_request_id");
82
+ }
83
+ get state() {
84
+ return own(this.body, "state");
85
+ }
86
+ get page() {
87
+ return own(this.body, "page");
88
+ }
89
+ get iterationState() {
90
+ return own(this.body, "iteration_state");
91
+ }
92
+ get responseJson() {
93
+ return own(this.body, "response_json");
94
+ }
95
+ get error() {
96
+ return own(this.body, "error");
97
+ }
98
+ get dedupeKey() {
99
+ const pageRequestId = this.pageRequestId;
100
+ const state = this.state;
101
+ if (pageRequestId === null || state === null) return null;
102
+ return `${pageRequestId}:${state}`;
103
+ }
104
+ }
105
+
106
+ function newerThan(revision, stored) {
107
+ if (stored === null || stored === undefined) return true;
108
+ return revision !== null && revision > stored;
109
+ }
110
+
111
+ class WorkflowRunEvent extends Event {
112
+ get type() {
113
+ return "workflow_run";
114
+ }
115
+ get runId() {
116
+ return own(this.body, "run_id");
117
+ }
118
+ get reference() {
119
+ return own(this.body, "reference");
120
+ }
121
+ get revision() {
122
+ return own(this.body, "revision");
123
+ }
124
+ get runState() {
125
+ return own(this.body, "run_state");
126
+ }
127
+ get workflow() {
128
+ return own(this.body, "workflow");
129
+ }
130
+ get connectionId() {
131
+ return own(this.body, "connection_id");
132
+ }
133
+ get prompt() {
134
+ return own(this.body, "prompt");
135
+ }
136
+ get outcome() {
137
+ return own(this.body, "outcome");
138
+ }
139
+ get output() {
140
+ return own(this.body, "output");
141
+ }
142
+ get error() {
143
+ return own(this.body, "error");
144
+ }
145
+ get summary() {
146
+ return own(this.body, "summary");
147
+ }
148
+ get cancelReason() {
149
+ return own(this.body, "cancel_reason");
150
+ }
151
+ get links() {
152
+ return own(this.body, "links");
153
+ }
154
+ newerThan(storedRevision) {
155
+ return newerThan(this.revision, storedRevision);
156
+ }
157
+ }
158
+
159
+ // A notification is data a run sends along the way: it carries a revision but
160
+ // is never skipped by it, so it has no newerThan. Dedupe on deliveryId.
161
+ class NotificationEvent extends Event {
162
+ get type() {
163
+ return "notification";
164
+ }
165
+ get runId() {
166
+ return own(this.body, "run_id");
167
+ }
168
+ get reference() {
169
+ return own(this.body, "reference");
170
+ }
171
+ get revision() {
172
+ return own(this.body, "revision");
173
+ }
174
+ get deliveryId() {
175
+ return own(this.body, "delivery_id");
176
+ }
177
+ get seq() {
178
+ return own(this.body, "seq");
179
+ }
180
+ get name() {
181
+ return own(this.body, "name");
182
+ }
183
+ get state() {
184
+ return own(this.body, "state");
185
+ }
186
+ get from() {
187
+ return own(this.body, "from");
188
+ }
189
+ get data() {
190
+ return own(this.body, "data");
191
+ }
192
+ get dedupeKey() {
193
+ return this.deliveryId;
194
+ }
195
+ }
196
+
197
+ class ConnectionEvent extends Event {
198
+ get type() {
199
+ return "connection";
200
+ }
201
+ get deliveryId() {
202
+ return own(this.body, "delivery_id");
203
+ }
204
+ get revision() {
205
+ return own(this.body, "revision");
206
+ }
207
+ get connection() {
208
+ return own(this.body, "connection");
209
+ }
210
+ get connectionId() {
211
+ return own(this.connection, "id");
212
+ }
213
+ get stallReason() {
214
+ const fromConnection = own(this.connection, "stall_reason");
215
+ return fromConnection !== null ? fromConnection : own(this.body, "stall_reason");
216
+ }
217
+ get dedupeKey() {
218
+ return this.deliveryId;
219
+ }
220
+ newerThan(storedRevision) {
221
+ return newerThan(this.revision, storedRevision);
222
+ }
223
+ }
224
+
225
+ class NameIndexEvent extends Event {
226
+ get type() {
227
+ return "name_index";
228
+ }
229
+ get connectionId() {
230
+ return own(this.body, "connection_id");
231
+ }
232
+ get nameIndex() {
233
+ return own(this.body, "name_index");
234
+ }
235
+ }
236
+
237
+ function eventClassFor(body) {
238
+ const event = own(body, "event");
239
+ if (event === null) {
240
+ return own(body, "page_request_id") !== null ? RequestEvent : UnknownEvent;
241
+ }
242
+ if (typeof event !== "string") return UnknownEvent;
243
+ if (WORKFLOW_RUN_EVENTS.includes(event)) return WorkflowRunEvent;
244
+ if (event === NOTIFICATION_EVENT) return NotificationEvent;
245
+ if (event.startsWith("connection.")) return ConnectionEvent;
246
+ if (event.startsWith("name_index.")) return NameIndexEvent;
247
+ return UnknownEvent;
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Arguments
252
+
253
+ function bodyBytes(rawBody) {
254
+ if (Buffer.isBuffer(rawBody)) return rawBody;
255
+ if (typeof rawBody === "string") return Buffer.from(rawBody, "utf8");
256
+ if (rawBody instanceof Uint8Array) {
257
+ return Buffer.from(rawBody.buffer, rawBody.byteOffset, rawBody.byteLength);
258
+ }
259
+ if (rawBody !== null && typeof rawBody === "object") {
260
+ throw new TypeError(
261
+ "rawBody is a parsed object: pass the raw request body (a Buffer or string, exactly as received), " +
262
+ 'for example from express.raw({ type: "application/json" })'
263
+ );
264
+ }
265
+ throw new TypeError(
266
+ `rawBody must be the raw request body as a Buffer or string, got ${rawBody === null ? "null" : typeof rawBody}`
267
+ );
268
+ }
269
+
270
+ function secretList(secrets) {
271
+ const list = typeof secrets === "string" || Buffer.isBuffer(secrets) ? [secrets] : secrets;
272
+ if (!Array.isArray(list)) {
273
+ throw new TypeError("secrets must be a webhook secret string or an array of them");
274
+ }
275
+ if (list.length === 0) {
276
+ throw new TypeError("secrets is empty: pass at least one webhook secret");
277
+ }
278
+ list.forEach((secret, index) => {
279
+ const ok = (typeof secret === "string" || Buffer.isBuffer(secret)) && secret.length > 0;
280
+ if (!ok) throw new TypeError(`secrets[${index}] must be a non-empty string`);
281
+ });
282
+ return list;
283
+ }
284
+
285
+ function nowSeconds(now) {
286
+ if (now === undefined || now === null) return Math.floor(Date.now() / 1000);
287
+ if (now instanceof Date) {
288
+ const ms = now.getTime();
289
+ if (Number.isNaN(ms)) throw new TypeError("now is an invalid Date");
290
+ return Math.floor(ms / 1000);
291
+ }
292
+ if (typeof now === "number" && Number.isFinite(now)) return Math.floor(now);
293
+ throw new TypeError("now must be unix seconds (a number) or a Date");
294
+ }
295
+
296
+ function toleranceSeconds(tolerance) {
297
+ if (tolerance === undefined || tolerance === null) return DEFAULT_TOLERANCE;
298
+ if (typeof tolerance === "number" && Number.isFinite(tolerance) && tolerance >= 0) return tolerance;
299
+ throw new TypeError("tolerance must be a non-negative number of seconds");
300
+ }
301
+
302
+ function headerString(header) {
303
+ if (Array.isArray(header)) return header.join(",");
304
+ if (header === undefined || header === null || typeof header === "string") return header;
305
+ throw new TypeError("header must be the X-QUBE-Signature header value (a string)");
306
+ }
307
+
308
+ // Splits "t=...,v1=...,v1=..." into the first t and every non-empty v1.
309
+ function parseHeader(header) {
310
+ let t = null;
311
+ const signatures = [];
312
+ for (const rawPart of header.split(",")) {
313
+ const part = rawPart.trim();
314
+ const eq = part.indexOf("=");
315
+ if (eq === -1) continue;
316
+ const key = part.slice(0, eq);
317
+ const value = part.slice(eq + 1);
318
+ if (key === "t" && t === null) t = value;
319
+ else if (key === "v1" && value !== "") signatures.push(value);
320
+ }
321
+ return { t, signatures };
322
+ }
323
+
324
+ function constantTimeEqual(expected, candidate) {
325
+ const a = Buffer.from(expected, "utf8");
326
+ const b = Buffer.from(candidate, "utf8");
327
+ if (a.length !== b.length) return false;
328
+ return crypto.timingSafeEqual(a, b);
329
+ }
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Public API
333
+
334
+ function parse(rawBody) {
335
+ const text = bodyBytes(rawBody).toString("utf8");
336
+ const body = JSON.parse(text);
337
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
338
+ throw new TypeError("webhook body is not a JSON object");
339
+ }
340
+ const EventClass = eventClassFor(body);
341
+ return new EventClass(body);
342
+ }
343
+
344
+ function verify(rawBody, header, options) {
345
+ const opts = options === undefined || options === null ? {} : options;
346
+ if (typeof opts !== "object") {
347
+ throw new TypeError("verify(rawBody, header, { secrets, tolerance, now }): options must be an object");
348
+ }
349
+ const bytes = bodyBytes(rawBody);
350
+ const secrets = secretList(opts.secrets);
351
+ const tolerance = toleranceSeconds(opts.tolerance);
352
+ const now = nowSeconds(opts.now);
353
+ const value = headerString(header);
354
+
355
+ if (value === undefined || value === null || value.trim() === "") {
356
+ throw new SignatureError("missing", `webhook signature missing: no ${SIGNATURE_HEADER} header`);
357
+ }
358
+
359
+ const { t, signatures } = parseHeader(value);
360
+ if (t === null || !/^[0-9]+$/.test(t) || signatures.length === 0) {
361
+ throw new SignatureError(
362
+ "malformed",
363
+ `webhook signature malformed: ${SIGNATURE_HEADER} needs t=<unix seconds> and at least one v1=<hex>`
364
+ );
365
+ }
366
+
367
+ const age = now - Number(t);
368
+ if (Math.abs(age) > tolerance) {
369
+ const when = age > 0 ? `${age} seconds old` : `${-age} seconds in the future`;
370
+ throw new SignatureError(
371
+ "stale",
372
+ `webhook signature stale: its timestamp is ${when} (tolerance ${tolerance} seconds)`
373
+ );
374
+ }
375
+
376
+ let matched = false;
377
+ for (const secret of secrets) {
378
+ const expected = crypto.createHmac("sha256", secret).update(`${t}.`, "utf8").update(bytes).digest("hex");
379
+ for (const candidate of signatures) {
380
+ if (constantTimeEqual(expected, candidate)) matched = true;
381
+ }
382
+ }
383
+ if (!matched) {
384
+ throw new SignatureError("mismatch", "webhook signature mismatch: no v1 matches any of the secrets");
385
+ }
386
+
387
+ return parse(bytes);
388
+ }
389
+
390
+ module.exports = {
391
+ VERSION,
392
+ SIGNATURE_HEADER,
393
+ DEFAULT_TOLERANCE,
394
+ REASONS,
395
+ SignatureError,
396
+ verify,
397
+ parse,
398
+ Event,
399
+ RequestEvent,
400
+ WorkflowRunEvent,
401
+ NotificationEvent,
402
+ ConnectionEvent,
403
+ NameIndexEvent,
404
+ UnknownEvent,
405
+ };
package/index.mjs ADDED
@@ -0,0 +1,20 @@
1
+ import qubesync from "./index.js";
2
+
3
+ export const {
4
+ VERSION,
5
+ SIGNATURE_HEADER,
6
+ DEFAULT_TOLERANCE,
7
+ REASONS,
8
+ SignatureError,
9
+ verify,
10
+ parse,
11
+ Event,
12
+ RequestEvent,
13
+ WorkflowRunEvent,
14
+ NotificationEvent,
15
+ ConnectionEvent,
16
+ NameIndexEvent,
17
+ UnknownEvent,
18
+ } = qubesync;
19
+
20
+ export default qubesync;
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "qubesync",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Verify and parse QuBe Sync webhooks (QuickBooks Desktop through qubesync.com).",
5
+ "license": "MIT",
6
+ "author": "QuBe Sync",
7
+ "homepage": "https://docs.qubesync.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/qubeintegrations/qube_sync_node.git"
11
+ },
12
+ "bugs": "https://github.com/qubeintegrations/qube_sync_node/issues",
13
+ "keywords": [
14
+ "qubesync",
15
+ "quickbooks",
16
+ "webhooks",
17
+ "hmac"
18
+ ],
19
+ "main": "./index.js",
20
+ "types": "./index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./index.d.ts",
24
+ "import": "./index.mjs",
25
+ "require": "./index.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "index.js",
31
+ "index.mjs",
32
+ "index.d.ts",
33
+ "README.md",
34
+ "CHANGELOG.md",
35
+ "LICENSE"
36
+ ],
37
+ "engines": {
38
+ "node": ">=16.17"
39
+ },
40
+ "scripts": {
41
+ "test": "node --test test/webhooks.test.js test/esm.test.mjs",
42
+ "test:types": "npx -y -p typescript@5.6.3 tsc --noEmit --strict --module node16 --moduleResolution node16 --target es2020 test/types.ts"
43
+ }
44
+ }