@nest-batch/webhook 0.2.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/LICENSE +21 -0
- package/README.md +395 -0
- package/dist/src/index.d.ts +32 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +71 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/module-options.d.ts +163 -0
- package/dist/src/module-options.d.ts.map +1 -0
- package/dist/src/module-options.js +124 -0
- package/dist/src/module-options.js.map +1 -0
- package/dist/src/webhook-batch.module.d.ts +59 -0
- package/dist/src/webhook-batch.module.d.ts.map +1 -0
- package/dist/src/webhook-batch.module.js +94 -0
- package/dist/src/webhook-batch.module.js.map +1 -0
- package/dist/src/webhook-batch.observer.d.ts +144 -0
- package/dist/src/webhook-batch.observer.d.ts.map +1 -0
- package/dist/src/webhook-batch.observer.js +306 -0
- package/dist/src/webhook-batch.observer.js.map +1 -0
- package/dist/src/webhook-signing.d.ts +70 -0
- package/dist/src/webhook-signing.d.ts.map +1 -0
- package/dist/src/webhook-signing.js +129 -0
- package/dist/src/webhook-signing.js.map +1 -0
- package/package.json +69 -0
- package/src/index.ts +46 -0
- package/src/module-options.ts +276 -0
- package/src/webhook-batch.module.ts +133 -0
- package/src/webhook-batch.observer.ts +408 -0
- package/src/webhook-signing.ts +185 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", {
|
|
3
|
+
value: true
|
|
4
|
+
});
|
|
5
|
+
function _export(target, all) {
|
|
6
|
+
for(var name in all)Object.defineProperty(target, name, {
|
|
7
|
+
enumerable: true,
|
|
8
|
+
get: Object.getOwnPropertyDescriptor(all, name).get
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
_export(exports, {
|
|
12
|
+
get BATCH_EVENT () {
|
|
13
|
+
return _core.BATCH_EVENT;
|
|
14
|
+
},
|
|
15
|
+
get WebhookBatchObserver () {
|
|
16
|
+
return WebhookBatchObserver;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
const _common = require("@nestjs/common");
|
|
20
|
+
const _core = require("@nest-batch/core");
|
|
21
|
+
const _moduleoptions = require("./module-options");
|
|
22
|
+
const _webhooksigning = require("./webhook-signing");
|
|
23
|
+
function _ts_decorate(decorators, target, key, desc) {
|
|
24
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
25
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
26
|
+
else for(var i = decorators.length - 1; i >= 0; i--)if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
27
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
28
|
+
}
|
|
29
|
+
function _ts_metadata(k, v) {
|
|
30
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
31
|
+
}
|
|
32
|
+
function _ts_param(paramIndex, decorator) {
|
|
33
|
+
return function(target, key) {
|
|
34
|
+
decorator(target, key, paramIndex);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
let WebhookBatchObserver = class WebhookBatchObserver {
|
|
38
|
+
logger;
|
|
39
|
+
/** Resolved + frozen options. The secret lives here and nowhere else. */ options;
|
|
40
|
+
/**
|
|
41
|
+
* Cached lookup of the subscription set. Built once at
|
|
42
|
+
* construction time so `onEvent` is a single `Set.has` check.
|
|
43
|
+
*/ subscribed;
|
|
44
|
+
/**
|
|
45
|
+
* Test-only override for the retry schedule. When
|
|
46
|
+
* `process.env.WEBHOOK_TEST_FAST === '1'`, the schedule is
|
|
47
|
+
* `[1ms, 5ms, 25ms, 125ms]` so the suite can exercise the
|
|
48
|
+
* 4-attempt path without waiting 156 seconds. The override
|
|
49
|
+
* is gated behind an env var so production cannot trip it
|
|
50
|
+
* by accident.
|
|
51
|
+
*/ retryDelaysMs;
|
|
52
|
+
/**
|
|
53
|
+
* Sentinel subscriber set: defaults to
|
|
54
|
+
* `[JOB_COMPLETED, JOB_FAILED, STEP_FAILED]`. Overridable via
|
|
55
|
+
* the `events` option in `forRoot({...})`.
|
|
56
|
+
*/ constructor(options){
|
|
57
|
+
this.options = options;
|
|
58
|
+
this.logger = options.logger ?? new _common.Logger(WebhookBatchObserver.name);
|
|
59
|
+
this.subscribed = new Set(options.events);
|
|
60
|
+
this.retryDelaysMs = process.env['WEBHOOK_TEST_FAST'] === '1' ? _moduleoptions.FAST_WEBHOOK_RETRY_DELAYS_MS : _moduleoptions.DEFAULT_WEBHOOK_RETRY_DELAYS_MS;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* `BatchObserver` entry point. Filters by the subscription
|
|
64
|
+
* set, then dispatches to every URL. NEVER throws — a slow /
|
|
65
|
+
* failing observer must not poison the executor (the
|
|
66
|
+
* JobExecutor already swallows observer errors, but we are
|
|
67
|
+
* defensive in depth).
|
|
68
|
+
*/ async onEvent(event) {
|
|
69
|
+
if (!this.subscribed.has(event.type)) return;
|
|
70
|
+
if (this.options.urls.length === 0) return;
|
|
71
|
+
try {
|
|
72
|
+
await this.deliverToAll(event);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// Defence in depth: the JobExecutor already swallows
|
|
75
|
+
// observer errors, but we re-assert it here so a single
|
|
76
|
+
// failing URL cannot starve the rest. The per-URL
|
|
77
|
+
// delivery loop has its own try/catch and writes a
|
|
78
|
+
// dead-letter `warn` for fully-failed URLs, so this
|
|
79
|
+
// outer catch only fires for genuinely unexpected
|
|
80
|
+
// errors (e.g. a synchronous throw in the envelope
|
|
81
|
+
// builder). The secret is NEVER included in this
|
|
82
|
+
// message.
|
|
83
|
+
this.logger.warn(`unexpected observer error type=${event.type} ` + `jobExecutionId=${event.jobExecutionId}: ` + `${err instanceof Error ? err.message : String(err)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// -----------------------------------------------------------------------
|
|
87
|
+
// Fan-out
|
|
88
|
+
// -----------------------------------------------------------------------
|
|
89
|
+
/**
|
|
90
|
+
* Build the envelope once, then POST to every URL in
|
|
91
|
+
* `urls` in parallel. A single URL's retry exhaustion does
|
|
92
|
+
* not affect the other URLs — each URL has its own
|
|
93
|
+
* `deliverToUrl` invocation and its own dead-letter line.
|
|
94
|
+
*
|
|
95
|
+
* The envelope is built with `JSON.stringify` (NOT a Nest
|
|
96
|
+
* serializer) so the bytes are stable and match the HMAC
|
|
97
|
+
* input byte-for-byte. The body string is the literal
|
|
98
|
+
* argument to `fetch`, so the receiver sees the same
|
|
99
|
+
* bytes the observer signed.
|
|
100
|
+
*/ async deliverToAll(event) {
|
|
101
|
+
const envelope = this.buildEnvelope(event);
|
|
102
|
+
const body = JSON.stringify(envelope);
|
|
103
|
+
await Promise.all(this.options.urls.map((url)=>this.deliverToUrl(url, event, body)));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Build the v1 envelope payload. The shape is the contract
|
|
107
|
+
* the receiver expects; changing it is a breaking change.
|
|
108
|
+
*
|
|
109
|
+
* - `version: 1` — the envelope schema version (the
|
|
110
|
+
* `v1=` in the signature header is the SIGNATURE
|
|
111
|
+
* version, not the ENVELOPE version; they are
|
|
112
|
+
* independent).
|
|
113
|
+
* - `type` — the `BatchEvent.type` string verbatim
|
|
114
|
+
* (e.g. `nest-batch.job.completed`).
|
|
115
|
+
* - `timestamp` — the event's `Date` serialized as
|
|
116
|
+
* ISO-8601 (the original `Date` is not JSON-safe).
|
|
117
|
+
* - `jobId` — the `jobExecutionId` (the `BatchEvent`
|
|
118
|
+
* contract guarantees this is always set).
|
|
119
|
+
* - `execution` — the `JobExecution` shape derived from
|
|
120
|
+
* the event's `data` payload. The observer treats
|
|
121
|
+
* `data` as opaque `JsonValue` and passes it through
|
|
122
|
+
* after a defensive deep-copy via `structuredClone`
|
|
123
|
+
* so the observer cannot mutate the executor's
|
|
124
|
+
* internal state by reference.
|
|
125
|
+
* - `stepId` — present for STEP\_\* / CHUNK\_\* / ITEM\_\*
|
|
126
|
+
* events; absent for JOB\_\* events. Mirrors the
|
|
127
|
+
* `BatchEvent.stepExecutionId` contract.
|
|
128
|
+
*/ buildEnvelope(event) {
|
|
129
|
+
return {
|
|
130
|
+
version: 1,
|
|
131
|
+
type: event.type,
|
|
132
|
+
timestamp: event.timestamp.toISOString(),
|
|
133
|
+
jobId: event.jobExecutionId,
|
|
134
|
+
...event.stepExecutionId !== undefined ? {
|
|
135
|
+
stepId: event.stepExecutionId
|
|
136
|
+
} : {},
|
|
137
|
+
execution: cloneJson(event.data)
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// -----------------------------------------------------------------------
|
|
141
|
+
// Per-URL delivery with retry
|
|
142
|
+
// -----------------------------------------------------------------------
|
|
143
|
+
/**
|
|
144
|
+
* POST the envelope to one URL with the full retry budget.
|
|
145
|
+
* Stops on the first 2xx; retries on 5xx and network errors;
|
|
146
|
+
* does NOT retry on 4xx; emits a dead-letter `warn` on
|
|
147
|
+
* exhaustion. The body is signed once; the same signed body
|
|
148
|
+
* is sent on every attempt.
|
|
149
|
+
*/ async deliverToUrl(url, event, body) {
|
|
150
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
151
|
+
const signature = (0, _webhooksigning.buildSignatureHeader)(this.options.secret, timestamp, body);
|
|
152
|
+
const fingerprint = (0, _webhooksigning.fingerprintSecret)(this.options.secret);
|
|
153
|
+
const totalAttempts = this.options.attempts;
|
|
154
|
+
let lastStatus;
|
|
155
|
+
let lastError;
|
|
156
|
+
for(let attempt = 1; attempt <= totalAttempts; attempt++){
|
|
157
|
+
const result = await this.attemptOnce(url, body, signature, timestamp);
|
|
158
|
+
if (result.kind === 'success') {
|
|
159
|
+
if (attempt > 1) {
|
|
160
|
+
this.logger.log(`delivered url=${url} type=${event.type} ` + `jobExecutionId=${event.jobExecutionId} attempt=${attempt}/${totalAttempts} ` + `status=${result.status} (after retry)`);
|
|
161
|
+
} else {
|
|
162
|
+
this.logger.debug(`delivered url=${url} type=${event.type} ` + `jobExecutionId=${event.jobExecutionId} attempt=${attempt}/${totalAttempts} ` + `status=${result.status}`);
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (result.kind === 'client-error') {
|
|
167
|
+
// 4xx — NO retry. Log at `warn` and return. The host
|
|
168
|
+
// is expected to fix the misconfiguration (bad URL,
|
|
169
|
+
// missing auth, malformed payload). The signature
|
|
170
|
+
// fingerprint and the attempt count are included so
|
|
171
|
+
// the host can correlate with the receiver's logs.
|
|
172
|
+
this.logger.warn(`[WebhookBatchObserver] dead-letter url=${url} attempts=${attempt} ` + `lastStatus=${result.status} lastError=HTTP ${result.status} ` + `type=${event.type} jobExecutionId=${event.jobExecutionId} ` + `secret_sha256=${fingerprint}`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// result.kind === 'server-error' | 'network-error'
|
|
176
|
+
lastStatus = result.kind === 'server-error' ? result.status : undefined;
|
|
177
|
+
lastError = result.kind === 'server-error' ? `HTTP ${result.status}` : result.error;
|
|
178
|
+
if (attempt < totalAttempts) {
|
|
179
|
+
// The retry schedule has exactly `attempts - 1`
|
|
180
|
+
// entries (delays BETWEEN attempts). When attempts
|
|
181
|
+
// is < 4 (test override), the array is sliced to
|
|
182
|
+
// match — we do not extend the schedule.
|
|
183
|
+
const delayIndex = Math.min(attempt - 1, this.retryDelaysMs.length - 1);
|
|
184
|
+
const delayMs = this.retryDelaysMs[delayIndex] ?? 0;
|
|
185
|
+
this.logger.debug(`retry url=${url} attempt=${attempt}/${totalAttempts} ` + `status=${lastStatus ?? 'n/a'} lastError=${lastError} ` + `nextDelayMs=${delayMs}`);
|
|
186
|
+
await sleep(delayMs);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Final failure — log dead-letter. NEVER include the
|
|
190
|
+
// secret. The fingerprint is a SHA-256 prefix (12 hex
|
|
191
|
+
// chars) that operators can use to correlate dead-letters
|
|
192
|
+
// across services without exposing the secret.
|
|
193
|
+
this.logger.warn(`[WebhookBatchObserver] dead-letter url=${url} attempts=${totalAttempts} ` + `lastStatus=${lastStatus ?? 'n/a'} lastError=${lastError ?? 'n/a'} ` + `type=${event.type} jobExecutionId=${event.jobExecutionId} ` + `secret_sha256=${fingerprint}`);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Single POST attempt. The signature header is sent on
|
|
197
|
+
* every attempt (the body bytes are identical across
|
|
198
|
+
* attempts; the receiver can verify the signature against
|
|
199
|
+
* any of them).
|
|
200
|
+
*
|
|
201
|
+
* The result is a discriminated union:
|
|
202
|
+
* - `kind: 'success'` — 2xx (or 3xx; we follow
|
|
203
|
+
* the redirect by default in `fetch`, but the
|
|
204
|
+
* receiver's terminal status is what we report)
|
|
205
|
+
* - `kind: 'client-error'` — 4xx (no retry)
|
|
206
|
+
* - `kind: 'server-error'` — 5xx (retry)
|
|
207
|
+
* - `kind: 'network-error'` — fetch threw, or
|
|
208
|
+
* `AbortError` from the timeout (retry)
|
|
209
|
+
*/ async attemptOnce(url, body, signature, timestamp) {
|
|
210
|
+
const controller = new AbortController();
|
|
211
|
+
const timer = setTimeout(()=>controller.abort(), this.options.timeoutMs);
|
|
212
|
+
try {
|
|
213
|
+
const response = await fetch(url, {
|
|
214
|
+
method: 'POST',
|
|
215
|
+
headers: {
|
|
216
|
+
'content-type': 'application/json',
|
|
217
|
+
[_webhooksigning.SIGNATURE_HEADER_NAME]: signature,
|
|
218
|
+
'x-nest-batch-timestamp': String(timestamp)
|
|
219
|
+
},
|
|
220
|
+
body,
|
|
221
|
+
signal: controller.signal,
|
|
222
|
+
// fetch follows 3xx redirects by default; the
|
|
223
|
+
// redirect target's terminal status drives the
|
|
224
|
+
// retry decision per the v1 contract
|
|
225
|
+
// (`docs/RELEASE-0.2.0.md` §7.3).
|
|
226
|
+
redirect: 'follow'
|
|
227
|
+
});
|
|
228
|
+
const status = response.status;
|
|
229
|
+
if (status >= 200 && status < 300) {
|
|
230
|
+
return {
|
|
231
|
+
kind: 'success',
|
|
232
|
+
status
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (status >= 400 && status < 500) {
|
|
236
|
+
return {
|
|
237
|
+
kind: 'client-error',
|
|
238
|
+
status
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
// 5xx (and 3xx that somehow slipped past redirect:
|
|
242
|
+
// should not happen with `redirect: 'follow'`, but
|
|
243
|
+
// be defensive). Treat anything >= 500 as a
|
|
244
|
+
// server-error and retry.
|
|
245
|
+
return {
|
|
246
|
+
kind: 'server-error',
|
|
247
|
+
status
|
|
248
|
+
};
|
|
249
|
+
} catch (err) {
|
|
250
|
+
// Network error, DNS failure, AbortError from the
|
|
251
|
+
// timeout, etc. All treated as retryable per the v1
|
|
252
|
+
// contract.
|
|
253
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
254
|
+
return {
|
|
255
|
+
kind: 'network-error',
|
|
256
|
+
error: message
|
|
257
|
+
};
|
|
258
|
+
} finally{
|
|
259
|
+
clearTimeout(timer);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
WebhookBatchObserver = _ts_decorate([
|
|
264
|
+
(0, _common.Injectable)(),
|
|
265
|
+
_ts_param(0, (0, _common.Inject)(_moduleoptions.WEBHOOK_MODULE_OPTIONS)),
|
|
266
|
+
_ts_metadata("design:type", Function),
|
|
267
|
+
_ts_metadata("design:paramtypes", [
|
|
268
|
+
typeof ResolvedWebhookOptions === "undefined" ? Object : ResolvedWebhookOptions
|
|
269
|
+
])
|
|
270
|
+
], WebhookBatchObserver);
|
|
271
|
+
// -----------------------------------------------------------------------
|
|
272
|
+
// Internal helpers
|
|
273
|
+
// -----------------------------------------------------------------------
|
|
274
|
+
/**
|
|
275
|
+
* Sleep for `ms` milliseconds. Returns a promise that
|
|
276
|
+
* resolves with no value. Used between retry attempts. The
|
|
277
|
+
* `ms <= 0` short-circuit keeps the fast-mode test
|
|
278
|
+
* schedule (`1ms`, `5ms`, ...) from producing timer
|
|
279
|
+
* warnings.
|
|
280
|
+
*/ function sleep(ms) {
|
|
281
|
+
if (ms <= 0) return Promise.resolve();
|
|
282
|
+
return new Promise((resolve)=>{
|
|
283
|
+
setTimeout(resolve, ms);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Defensive deep-clone of an arbitrary `JsonValue`. The
|
|
288
|
+
* `BatchEvent.data` field is typed as `JsonValue` and is
|
|
289
|
+
* shared with the executor's internal state; we clone it
|
|
290
|
+
* so the observer cannot mutate the executor by reference.
|
|
291
|
+
* `structuredClone` is available in Node 17+ and is the
|
|
292
|
+
* fastest safe deep-clone for JSON-shaped data.
|
|
293
|
+
*/ function cloneJson(value) {
|
|
294
|
+
if (value === null || typeof value !== 'object') return value;
|
|
295
|
+
// structuredClone can throw on non-cloneable values
|
|
296
|
+
// (e.g. functions, symbols). The BatchEvent contract
|
|
297
|
+
// guarantees `data` is `JsonValue`, so the catch is
|
|
298
|
+
// purely defensive.
|
|
299
|
+
try {
|
|
300
|
+
return structuredClone(value);
|
|
301
|
+
} catch {
|
|
302
|
+
return value;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
//# sourceMappingURL=webhook-batch.observer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/webhook-batch.observer.ts"],"sourcesContent":["import { Inject, Injectable, Logger } from '@nestjs/common';\nimport {\n BATCH_EVENT,\n type BatchEvent,\n type BatchEventType,\n type BatchObserver,\n} from '@nest-batch/core';\n\nimport {\n DEFAULT_WEBHOOK_RETRY_DELAYS_MS,\n FAST_WEBHOOK_RETRY_DELAYS_MS,\n WEBHOOK_MODULE_OPTIONS,\n type ResolvedWebhookOptions,\n type WebhookLogger,\n} from './module-options';\nimport {\n SIGNATURE_HEADER_NAME,\n buildSignatureHeader,\n fingerprintSecret,\n} from './webhook-signing';\n\n/**\n * `WebhookBatchObserver` — the v1 webhook delivery observer.\n *\n * Implements `BatchObserver` from `@nest-batch/core`. On every\n * subscribed `BATCH_EVENT.*` (default:\n * `[JOB_COMPLETED, JOB_FAILED, STEP_FAILED]`) the observer:\n *\n * 1. Serializes a normalized JSON envelope\n * `{ version: 1, type, timestamp, jobId, execution }`.\n * 2. Computes the v1 HMAC-SHA256 signature over\n * `<unix>.<raw-body>` (Stripe-style).\n * 3. POSTs the envelope + `X-Nest-Batch-Signature` header to\n * every URL in `urls`.\n * 4. Retries on 5xx and network errors through the fixed\n * 4-attempt budget at `[1s, 5s, 25s, 125s]`. HTTP 4xx\n * responses are NOT retried (client error, won't change).\n * 5. On final failure, emits a `logger.warn` dead-letter line\n * including the URL, attempt count, last status / error,\n * and a SHA-256 fingerprint of the secret (NEVER the\n * secret itself).\n *\n * The observer is the v1 contract documented in\n * `docs/RELEASE-0.2.0.md` §7 and pinned by T-AC-5\n * (`packages/webhook/tests/webhook-observer.test.ts`).\n */\n@Injectable()\nexport class WebhookBatchObserver implements BatchObserver {\n private readonly logger: WebhookLogger;\n\n /** Resolved + frozen options. The secret lives here and nowhere else. */\n private readonly options: ResolvedWebhookOptions;\n\n /**\n * Cached lookup of the subscription set. Built once at\n * construction time so `onEvent` is a single `Set.has` check.\n */\n private readonly subscribed: ReadonlySet<BatchEventType>;\n\n /**\n * Test-only override for the retry schedule. When\n * `process.env.WEBHOOK_TEST_FAST === '1'`, the schedule is\n * `[1ms, 5ms, 25ms, 125ms]` so the suite can exercise the\n * 4-attempt path without waiting 156 seconds. The override\n * is gated behind an env var so production cannot trip it\n * by accident.\n */\n private readonly retryDelaysMs: readonly number[];\n\n /**\n * Sentinel subscriber set: defaults to\n * `[JOB_COMPLETED, JOB_FAILED, STEP_FAILED]`. Overridable via\n * the `events` option in `forRoot({...})`.\n */\n constructor(\n @Inject(WEBHOOK_MODULE_OPTIONS) options: ResolvedWebhookOptions,\n ) {\n this.options = options;\n this.logger = options.logger ?? new Logger(WebhookBatchObserver.name);\n this.subscribed = new Set(options.events);\n this.retryDelaysMs =\n process.env['WEBHOOK_TEST_FAST'] === '1'\n ? FAST_WEBHOOK_RETRY_DELAYS_MS\n : DEFAULT_WEBHOOK_RETRY_DELAYS_MS;\n }\n\n /**\n * `BatchObserver` entry point. Filters by the subscription\n * set, then dispatches to every URL. NEVER throws — a slow /\n * failing observer must not poison the executor (the\n * JobExecutor already swallows observer errors, but we are\n * defensive in depth).\n */\n async onEvent(event: BatchEvent): Promise<void> {\n if (!this.subscribed.has(event.type)) return;\n if (this.options.urls.length === 0) return;\n try {\n await this.deliverToAll(event);\n } catch (err) {\n // Defence in depth: the JobExecutor already swallows\n // observer errors, but we re-assert it here so a single\n // failing URL cannot starve the rest. The per-URL\n // delivery loop has its own try/catch and writes a\n // dead-letter `warn` for fully-failed URLs, so this\n // outer catch only fires for genuinely unexpected\n // errors (e.g. a synchronous throw in the envelope\n // builder). The secret is NEVER included in this\n // message.\n this.logger.warn(\n `unexpected observer error type=${event.type} ` +\n `jobExecutionId=${event.jobExecutionId}: ` +\n `${err instanceof Error ? err.message : String(err)}`,\n );\n }\n }\n\n // -----------------------------------------------------------------------\n // Fan-out\n // -----------------------------------------------------------------------\n\n /**\n * Build the envelope once, then POST to every URL in\n * `urls` in parallel. A single URL's retry exhaustion does\n * not affect the other URLs — each URL has its own\n * `deliverToUrl` invocation and its own dead-letter line.\n *\n * The envelope is built with `JSON.stringify` (NOT a Nest\n * serializer) so the bytes are stable and match the HMAC\n * input byte-for-byte. The body string is the literal\n * argument to `fetch`, so the receiver sees the same\n * bytes the observer signed.\n */\n private async deliverToAll(event: BatchEvent): Promise<void> {\n const envelope = this.buildEnvelope(event);\n const body = JSON.stringify(envelope);\n await Promise.all(\n this.options.urls.map((url) => this.deliverToUrl(url, event, body)),\n );\n }\n\n /**\n * Build the v1 envelope payload. The shape is the contract\n * the receiver expects; changing it is a breaking change.\n *\n * - `version: 1` — the envelope schema version (the\n * `v1=` in the signature header is the SIGNATURE\n * version, not the ENVELOPE version; they are\n * independent).\n * - `type` — the `BatchEvent.type` string verbatim\n * (e.g. `nest-batch.job.completed`).\n * - `timestamp` — the event's `Date` serialized as\n * ISO-8601 (the original `Date` is not JSON-safe).\n * - `jobId` — the `jobExecutionId` (the `BatchEvent`\n * contract guarantees this is always set).\n * - `execution` — the `JobExecution` shape derived from\n * the event's `data` payload. The observer treats\n * `data` as opaque `JsonValue` and passes it through\n * after a defensive deep-copy via `structuredClone`\n * so the observer cannot mutate the executor's\n * internal state by reference.\n * - `stepId` — present for STEP\\_\\* / CHUNK\\_\\* / ITEM\\_\\*\n * events; absent for JOB\\_\\* events. Mirrors the\n * `BatchEvent.stepExecutionId` contract.\n */\n private buildEnvelope(event: BatchEvent): WebhookEnvelope {\n return {\n version: 1,\n type: event.type,\n timestamp: event.timestamp.toISOString(),\n jobId: event.jobExecutionId,\n ...(event.stepExecutionId !== undefined\n ? { stepId: event.stepExecutionId }\n : {}),\n execution: cloneJson(event.data),\n };\n }\n\n // -----------------------------------------------------------------------\n // Per-URL delivery with retry\n // -----------------------------------------------------------------------\n\n /**\n * POST the envelope to one URL with the full retry budget.\n * Stops on the first 2xx; retries on 5xx and network errors;\n * does NOT retry on 4xx; emits a dead-letter `warn` on\n * exhaustion. The body is signed once; the same signed body\n * is sent on every attempt.\n */\n private async deliverToUrl(\n url: string,\n event: BatchEvent,\n body: string,\n ): Promise<void> {\n const timestamp = Math.floor(Date.now() / 1000);\n const signature = buildSignatureHeader(\n this.options.secret,\n timestamp,\n body,\n );\n const fingerprint = fingerprintSecret(this.options.secret);\n\n const totalAttempts = this.options.attempts;\n let lastStatus: number | undefined;\n let lastError: string | undefined;\n\n for (let attempt = 1; attempt <= totalAttempts; attempt++) {\n const result = await this.attemptOnce(url, body, signature, timestamp);\n if (result.kind === 'success') {\n if (attempt > 1) {\n this.logger.log(\n `delivered url=${url} type=${event.type} ` +\n `jobExecutionId=${event.jobExecutionId} attempt=${attempt}/${totalAttempts} ` +\n `status=${result.status} (after retry)`,\n );\n } else {\n this.logger.debug(\n `delivered url=${url} type=${event.type} ` +\n `jobExecutionId=${event.jobExecutionId} attempt=${attempt}/${totalAttempts} ` +\n `status=${result.status}`,\n );\n }\n return;\n }\n if (result.kind === 'client-error') {\n // 4xx — NO retry. Log at `warn` and return. The host\n // is expected to fix the misconfiguration (bad URL,\n // missing auth, malformed payload). The signature\n // fingerprint and the attempt count are included so\n // the host can correlate with the receiver's logs.\n this.logger.warn(\n `[WebhookBatchObserver] dead-letter url=${url} attempts=${attempt} ` +\n `lastStatus=${result.status} lastError=HTTP ${result.status} ` +\n `type=${event.type} jobExecutionId=${event.jobExecutionId} ` +\n `secret_sha256=${fingerprint}`,\n );\n return;\n }\n // result.kind === 'server-error' | 'network-error'\n lastStatus = result.kind === 'server-error' ? result.status : undefined;\n lastError = result.kind === 'server-error'\n ? `HTTP ${result.status}`\n : result.error;\n\n if (attempt < totalAttempts) {\n // The retry schedule has exactly `attempts - 1`\n // entries (delays BETWEEN attempts). When attempts\n // is < 4 (test override), the array is sliced to\n // match — we do not extend the schedule.\n const delayIndex = Math.min(attempt - 1, this.retryDelaysMs.length - 1);\n const delayMs = this.retryDelaysMs[delayIndex] ?? 0;\n this.logger.debug(\n `retry url=${url} attempt=${attempt}/${totalAttempts} ` +\n `status=${lastStatus ?? 'n/a'} lastError=${lastError} ` +\n `nextDelayMs=${delayMs}`,\n );\n await sleep(delayMs);\n }\n }\n\n // Final failure — log dead-letter. NEVER include the\n // secret. The fingerprint is a SHA-256 prefix (12 hex\n // chars) that operators can use to correlate dead-letters\n // across services without exposing the secret.\n this.logger.warn(\n `[WebhookBatchObserver] dead-letter url=${url} attempts=${totalAttempts} ` +\n `lastStatus=${lastStatus ?? 'n/a'} lastError=${lastError ?? 'n/a'} ` +\n `type=${event.type} jobExecutionId=${event.jobExecutionId} ` +\n `secret_sha256=${fingerprint}`,\n );\n }\n\n /**\n * Single POST attempt. The signature header is sent on\n * every attempt (the body bytes are identical across\n * attempts; the receiver can verify the signature against\n * any of them).\n *\n * The result is a discriminated union:\n * - `kind: 'success'` — 2xx (or 3xx; we follow\n * the redirect by default in `fetch`, but the\n * receiver's terminal status is what we report)\n * - `kind: 'client-error'` — 4xx (no retry)\n * - `kind: 'server-error'` — 5xx (retry)\n * - `kind: 'network-error'` — fetch threw, or\n * `AbortError` from the timeout (retry)\n */\n private async attemptOnce(\n url: string,\n body: string,\n signature: string,\n timestamp: number,\n ): Promise<\n | { kind: 'success'; status: number }\n | { kind: 'client-error'; status: number }\n | { kind: 'server-error'; status: number }\n | { kind: 'network-error'; error: string }\n > {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), this.options.timeoutMs);\n try {\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n [SIGNATURE_HEADER_NAME]: signature,\n 'x-nest-batch-timestamp': String(timestamp),\n },\n body,\n signal: controller.signal,\n // fetch follows 3xx redirects by default; the\n // redirect target's terminal status drives the\n // retry decision per the v1 contract\n // (`docs/RELEASE-0.2.0.md` §7.3).\n redirect: 'follow',\n });\n const status = response.status;\n if (status >= 200 && status < 300) {\n return { kind: 'success', status };\n }\n if (status >= 400 && status < 500) {\n return { kind: 'client-error', status };\n }\n // 5xx (and 3xx that somehow slipped past redirect:\n // should not happen with `redirect: 'follow'`, but\n // be defensive). Treat anything >= 500 as a\n // server-error and retry.\n return { kind: 'server-error', status };\n } catch (err) {\n // Network error, DNS failure, AbortError from the\n // timeout, etc. All treated as retryable per the v1\n // contract.\n const message = err instanceof Error ? err.message : String(err);\n return { kind: 'network-error', error: message };\n } finally {\n clearTimeout(timer);\n }\n }\n}\n\n// -----------------------------------------------------------------------\n// Internal helpers\n// -----------------------------------------------------------------------\n\n/**\n * Sleep for `ms` milliseconds. Returns a promise that\n * resolves with no value. Used between retry attempts. The\n * `ms <= 0` short-circuit keeps the fast-mode test\n * schedule (`1ms`, `5ms`, ...) from producing timer\n * warnings.\n */\nfunction sleep(ms: number): Promise<void> {\n if (ms <= 0) return Promise.resolve();\n return new Promise((resolve) => {\n setTimeout(resolve, ms);\n });\n}\n\n/**\n * Defensive deep-clone of an arbitrary `JsonValue`. The\n * `BatchEvent.data` field is typed as `JsonValue` and is\n * shared with the executor's internal state; we clone it\n * so the observer cannot mutate the executor by reference.\n * `structuredClone` is available in Node 17+ and is the\n * fastest safe deep-clone for JSON-shaped data.\n */\nfunction cloneJson<T>(value: T): T {\n if (value === null || typeof value !== 'object') return value;\n // structuredClone can throw on non-cloneable values\n // (e.g. functions, symbols). The BatchEvent contract\n // guarantees `data` is `JsonValue`, so the catch is\n // purely defensive.\n try {\n return structuredClone(value) as T;\n } catch {\n return value;\n }\n}\n\n// -----------------------------------------------------------------------\n// Public types\n// -----------------------------------------------------------------------\n\n/**\n * The v1 webhook envelope payload. This is the contract the\n * receiver's parser expects. Fields are stable; new fields\n * are additive only and use the `x-` prefix to mark them\n * as out-of-contract for v1.\n */\nexport interface WebhookEnvelope {\n /** Envelope schema version. Always `1` for v1. */\n readonly version: 1;\n /** The `BatchEvent.type` string (e.g. `nest-batch.job.completed`). */\n readonly type: BatchEventType;\n /** Event timestamp as ISO-8601. */\n readonly timestamp: string;\n /** The `JobExecution.id` (a.k.a. `jobExecutionId`). */\n readonly jobId: string;\n /** The `StepExecution.id` (a.k.a. `stepExecutionId`). STEP\\_\\* / CHUNK\\_\\* / ITEM\\_\\* events only. */\n readonly stepId?: string;\n /** The `BatchEvent.data` payload, deep-cloned for safety. */\n readonly execution: unknown;\n}\n\n// Re-export the `BATCH_EVENT` constant so consumers can\n// reference the exact subscription set without having to\n// import `@nest-batch/core` themselves. The names of the\n// event types are part of the public surface.\nexport { BATCH_EVENT };\n"],"names":["BATCH_EVENT","WebhookBatchObserver","logger","options","subscribed","retryDelaysMs","Logger","name","Set","events","process","env","FAST_WEBHOOK_RETRY_DELAYS_MS","DEFAULT_WEBHOOK_RETRY_DELAYS_MS","onEvent","event","has","type","urls","length","deliverToAll","err","warn","jobExecutionId","Error","message","String","envelope","buildEnvelope","body","JSON","stringify","Promise","all","map","url","deliverToUrl","version","timestamp","toISOString","jobId","stepExecutionId","undefined","stepId","execution","cloneJson","data","Math","floor","Date","now","signature","buildSignatureHeader","secret","fingerprint","fingerprintSecret","totalAttempts","attempts","lastStatus","lastError","attempt","result","attemptOnce","kind","log","status","debug","error","delayIndex","min","delayMs","sleep","controller","AbortController","timer","setTimeout","abort","timeoutMs","response","fetch","method","headers","SIGNATURE_HEADER_NAME","signal","redirect","clearTimeout","ms","resolve","value","structuredClone"],"mappings":"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"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compute the v1 HMAC-SHA256 signature for the given (timestamp,
|
|
3
|
+
* raw body) pair.
|
|
4
|
+
*
|
|
5
|
+
* Returns the lowercase hex string the receiver compares against
|
|
6
|
+
* the `v1=` field. The function is timing-safe on the input
|
|
7
|
+
* (Node's `createHmac` is constant-time per the crypto spec), so
|
|
8
|
+
* it is safe to use for verification as well.
|
|
9
|
+
*
|
|
10
|
+
* @param secret The host-injected secret. Never logged, never
|
|
11
|
+
* serialized, never returned by the helper.
|
|
12
|
+
* @param timestamp The unix-seconds integer the signature is
|
|
13
|
+
* pinned to. Must be a positive integer; the helper does not
|
|
14
|
+
* validate the value (callers may pin it to `Math.floor(Date.now() / 1000)`).
|
|
15
|
+
* @param rawBody The exact JSON-serialized body bytes the
|
|
16
|
+
* request will POST. Must match the body the receiver HMACs.
|
|
17
|
+
*/
|
|
18
|
+
export declare function signV1(secret: string, timestamp: number, rawBody: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Build the full `X-Nest-Batch-Signature` header value for the
|
|
21
|
+
* given (timestamp, body) pair. The result is the literal
|
|
22
|
+
* header value, e.g. `t=1717941612,v1=4f3a...`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function buildSignatureHeader(secret: string, timestamp: number, rawBody: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Parse a `X-Nest-Batch-Signature` header value back into its
|
|
27
|
+
* parts. Used by the test server to extract the `t=` and
|
|
28
|
+
* `v1=` fields for byte-equality verification.
|
|
29
|
+
*
|
|
30
|
+
* Throws on malformed input. Does NOT verify the HMAC; the
|
|
31
|
+
* caller is expected to call `verifyV1` with the original body.
|
|
32
|
+
*/
|
|
33
|
+
export interface ParsedSignature {
|
|
34
|
+
readonly timestamp: number;
|
|
35
|
+
readonly v1: string;
|
|
36
|
+
}
|
|
37
|
+
export declare function parseSignatureHeader(header: string): ParsedSignature;
|
|
38
|
+
/**
|
|
39
|
+
* Timing-safe verification of a `X-Nest-Batch-Signature` header
|
|
40
|
+
* against a (secret, raw body) pair.
|
|
41
|
+
*
|
|
42
|
+
* Returns `true` iff the v1 HMAC matches. Uses `timingSafeEqual`
|
|
43
|
+
* to prevent timing-leak attacks on the comparison.
|
|
44
|
+
*
|
|
45
|
+
* Note: this helper is the SYMMETRIC counterpart of `signV1`.
|
|
46
|
+
* Receivers (the URL targets) call it after extracting the
|
|
47
|
+
* header value via `parseSignatureHeader`. The test suite uses
|
|
48
|
+
* it to assert byte-equality of the HMAC computed by the
|
|
49
|
+
* observer against the HMAC computed independently with the
|
|
50
|
+
* same secret + body.
|
|
51
|
+
*/
|
|
52
|
+
export declare function verifyV1(secret: string, timestamp: number, rawBody: string, candidateV1: string): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Compute a SHA-256 fingerprint of the secret for use in
|
|
55
|
+
* dead-letter log lines. The host NEVER wants the secret (or a
|
|
56
|
+
* substring of it) in a log line, but operators often want a
|
|
57
|
+
* stable identifier to correlate dead-letter lines across
|
|
58
|
+
* services ("all 4xx dead-letters today used secret_sha256=abc...").
|
|
59
|
+
*
|
|
60
|
+
* Returns the first 12 hex chars of `sha256(secret)` — enough
|
|
61
|
+
* to be useful as a correlation tag, short enough that it
|
|
62
|
+
* cannot be brute-forced back to the secret.
|
|
63
|
+
*/
|
|
64
|
+
export declare function fingerprintSecret(secret: string): string;
|
|
65
|
+
/**
|
|
66
|
+
* The literal header name. Re-exported so the test server and
|
|
67
|
+
* the observer never have to repeat the magic string.
|
|
68
|
+
*/
|
|
69
|
+
export declare const SIGNATURE_HEADER_NAME = "X-Nest-Batch-Signature";
|
|
70
|
+
//# sourceMappingURL=webhook-signing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-signing.d.ts","sourceRoot":"","sources":["../../src/webhook-signing.ts"],"names":[],"mappings":"AAgCA;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,MAAM,CACpB,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,GACd,MAAM,CAaR;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,GACd,MAAM,CAGR;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,eAAe,CA2BpE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,QAAQ,CACtB,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,GAClB,OAAO,CAiBT;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAKxD;AAED;;;GAGG;AACH,eAAO,MAAM,qBAAqB,2BAAmB,CAAC"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", {
|
|
3
|
+
value: true
|
|
4
|
+
});
|
|
5
|
+
function _export(target, all) {
|
|
6
|
+
for(var name in all)Object.defineProperty(target, name, {
|
|
7
|
+
enumerable: true,
|
|
8
|
+
get: Object.getOwnPropertyDescriptor(all, name).get
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
_export(exports, {
|
|
12
|
+
get SIGNATURE_HEADER_NAME () {
|
|
13
|
+
return SIGNATURE_HEADER_NAME;
|
|
14
|
+
},
|
|
15
|
+
get buildSignatureHeader () {
|
|
16
|
+
return buildSignatureHeader;
|
|
17
|
+
},
|
|
18
|
+
get fingerprintSecret () {
|
|
19
|
+
return fingerprintSecret;
|
|
20
|
+
},
|
|
21
|
+
get parseSignatureHeader () {
|
|
22
|
+
return parseSignatureHeader;
|
|
23
|
+
},
|
|
24
|
+
get signV1 () {
|
|
25
|
+
return signV1;
|
|
26
|
+
},
|
|
27
|
+
get verifyV1 () {
|
|
28
|
+
return verifyV1;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
const _nodecrypto = require("node:crypto");
|
|
32
|
+
/**
|
|
33
|
+
* HMAC-SHA256 signing helper for the outbound webhook envelope.
|
|
34
|
+
*
|
|
35
|
+
* The signature is shipped in the `X-Nest-Batch-Signature` header
|
|
36
|
+
* with the Stripe-style `t=<unix>,v1=<hex>` shape:
|
|
37
|
+
*
|
|
38
|
+
* X-Nest-Batch-Signature: t=1717941612,v1=4f3a2b...c1d
|
|
39
|
+
*
|
|
40
|
+
* Where:
|
|
41
|
+
* - `t` is the unix-seconds timestamp the receiver should use
|
|
42
|
+
* to enforce a replay window (recommended: 5 minutes).
|
|
43
|
+
* - `v1` is the lowercase hex of
|
|
44
|
+
* `HMAC_SHA256(secret, "<unix>.<raw-body>")`.
|
|
45
|
+
*
|
|
46
|
+
* The `<raw-body>` is the EXACT JSON-serialized request body bytes
|
|
47
|
+
* (not a re-serialization). Callers must pass the same string
|
|
48
|
+
* they POST — the helper does not re-serialize. This avoids the
|
|
49
|
+
* classic "server signed stringified JSON, client re-stringified
|
|
50
|
+
* with different key order" footgun.
|
|
51
|
+
*
|
|
52
|
+
* The `v1` key is the v1 contract; a future v2 may add
|
|
53
|
+
* `v2=`-prefixed scheme-version constants (e.g. a SHA-512
|
|
54
|
+
* variant). Receivers MUST reject unknown `vN` keys.
|
|
55
|
+
*
|
|
56
|
+
* Reference: `docs/RELEASE-0.2.0.md` §7.4.
|
|
57
|
+
*/ const SIGNATURE_HEADER = 'X-Nest-Batch-Signature';
|
|
58
|
+
const SIGNATURE_VERSION = 'v1';
|
|
59
|
+
function signV1(secret, timestamp, rawBody) {
|
|
60
|
+
if (typeof secret !== 'string' || secret.length === 0) {
|
|
61
|
+
throw new Error('[webhook-signing] secret must be a non-empty string');
|
|
62
|
+
}
|
|
63
|
+
if (!Number.isFinite(timestamp) || timestamp < 0) {
|
|
64
|
+
throw new Error('[webhook-signing] timestamp must be a non-negative number');
|
|
65
|
+
}
|
|
66
|
+
if (typeof rawBody !== 'string') {
|
|
67
|
+
throw new Error('[webhook-signing] rawBody must be a string');
|
|
68
|
+
}
|
|
69
|
+
const hmac = (0, _nodecrypto.createHmac)('sha256', secret);
|
|
70
|
+
hmac.update(`${timestamp}.${rawBody}`);
|
|
71
|
+
return hmac.digest('hex');
|
|
72
|
+
}
|
|
73
|
+
function buildSignatureHeader(secret, timestamp, rawBody) {
|
|
74
|
+
const v1 = signV1(secret, timestamp, rawBody);
|
|
75
|
+
return `t=${timestamp},${SIGNATURE_VERSION}=${v1}`;
|
|
76
|
+
}
|
|
77
|
+
function parseSignatureHeader(header) {
|
|
78
|
+
if (typeof header !== 'string' || header.length === 0) {
|
|
79
|
+
throw new Error('[webhook-signing] header is empty');
|
|
80
|
+
}
|
|
81
|
+
const parts = header.split(',').map((p)=>p.trim());
|
|
82
|
+
let timestamp;
|
|
83
|
+
let v1;
|
|
84
|
+
for (const part of parts){
|
|
85
|
+
if (part.startsWith('t=')) {
|
|
86
|
+
const raw = part.slice(2);
|
|
87
|
+
timestamp = Number.parseInt(raw, 10);
|
|
88
|
+
if (!Number.isFinite(timestamp) || timestamp < 0) {
|
|
89
|
+
throw new Error(`[webhook-signing] invalid t= value: ${raw}`);
|
|
90
|
+
}
|
|
91
|
+
} else if (part.startsWith(`${SIGNATURE_VERSION}=`)) {
|
|
92
|
+
v1 = part.slice(SIGNATURE_VERSION.length + 1);
|
|
93
|
+
if (v1.length === 0) {
|
|
94
|
+
throw new Error(`[webhook-signing] empty ${SIGNATURE_VERSION}= value`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (timestamp === undefined || v1 === undefined) {
|
|
99
|
+
throw new Error(`[webhook-signing] header missing t= or ${SIGNATURE_VERSION}= field: ${header}`);
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
timestamp,
|
|
103
|
+
v1
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function verifyV1(secret, timestamp, rawBody, candidateV1) {
|
|
107
|
+
const expected = signV1(secret, timestamp, rawBody);
|
|
108
|
+
// Both `expected` and `candidateV1` are lowercase hex of the
|
|
109
|
+
// same length for a given (secret, body) pair, so the equal-
|
|
110
|
+
// length precondition of `timingSafeEqual` holds. Defensive
|
|
111
|
+
// length check: if the candidate is the wrong length, return
|
|
112
|
+
// false without invoking the constant-time compare (the
|
|
113
|
+
// length itself is not a secret).
|
|
114
|
+
if (candidateV1.length !== expected.length) return false;
|
|
115
|
+
try {
|
|
116
|
+
return (0, _nodecrypto.timingSafeEqual)(Buffer.from(expected, 'hex'), Buffer.from(candidateV1, 'hex'));
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function fingerprintSecret(secret) {
|
|
122
|
+
if (typeof secret !== 'string' || secret.length === 0) {
|
|
123
|
+
return '<missing>';
|
|
124
|
+
}
|
|
125
|
+
return (0, _nodecrypto.createHash)('sha256').update(secret, 'utf8').digest('hex').slice(0, 12);
|
|
126
|
+
}
|
|
127
|
+
const SIGNATURE_HEADER_NAME = SIGNATURE_HEADER;
|
|
128
|
+
|
|
129
|
+
//# sourceMappingURL=webhook-signing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/webhook-signing.ts"],"sourcesContent":["import { createHmac, createHash, timingSafeEqual } from 'node:crypto';\n\n/**\n * HMAC-SHA256 signing helper for the outbound webhook envelope.\n *\n * The signature is shipped in the `X-Nest-Batch-Signature` header\n * with the Stripe-style `t=<unix>,v1=<hex>` shape:\n *\n * X-Nest-Batch-Signature: t=1717941612,v1=4f3a2b...c1d\n *\n * Where:\n * - `t` is the unix-seconds timestamp the receiver should use\n * to enforce a replay window (recommended: 5 minutes).\n * - `v1` is the lowercase hex of\n * `HMAC_SHA256(secret, \"<unix>.<raw-body>\")`.\n *\n * The `<raw-body>` is the EXACT JSON-serialized request body bytes\n * (not a re-serialization). Callers must pass the same string\n * they POST — the helper does not re-serialize. This avoids the\n * classic \"server signed stringified JSON, client re-stringified\n * with different key order\" footgun.\n *\n * The `v1` key is the v1 contract; a future v2 may add\n * `v2=`-prefixed scheme-version constants (e.g. a SHA-512\n * variant). Receivers MUST reject unknown `vN` keys.\n *\n * Reference: `docs/RELEASE-0.2.0.md` §7.4.\n */\n\nconst SIGNATURE_HEADER = 'X-Nest-Batch-Signature';\nconst SIGNATURE_VERSION = 'v1';\n\n/**\n * Compute the v1 HMAC-SHA256 signature for the given (timestamp,\n * raw body) pair.\n *\n * Returns the lowercase hex string the receiver compares against\n * the `v1=` field. The function is timing-safe on the input\n * (Node's `createHmac` is constant-time per the crypto spec), so\n * it is safe to use for verification as well.\n *\n * @param secret The host-injected secret. Never logged, never\n * serialized, never returned by the helper.\n * @param timestamp The unix-seconds integer the signature is\n * pinned to. Must be a positive integer; the helper does not\n * validate the value (callers may pin it to `Math.floor(Date.now() / 1000)`).\n * @param rawBody The exact JSON-serialized body bytes the\n * request will POST. Must match the body the receiver HMACs.\n */\nexport function signV1(\n secret: string,\n timestamp: number,\n rawBody: string,\n): string {\n if (typeof secret !== 'string' || secret.length === 0) {\n throw new Error('[webhook-signing] secret must be a non-empty string');\n }\n if (!Number.isFinite(timestamp) || timestamp < 0) {\n throw new Error('[webhook-signing] timestamp must be a non-negative number');\n }\n if (typeof rawBody !== 'string') {\n throw new Error('[webhook-signing] rawBody must be a string');\n }\n const hmac = createHmac('sha256', secret);\n hmac.update(`${timestamp}.${rawBody}`);\n return hmac.digest('hex');\n}\n\n/**\n * Build the full `X-Nest-Batch-Signature` header value for the\n * given (timestamp, body) pair. The result is the literal\n * header value, e.g. `t=1717941612,v1=4f3a...`.\n */\nexport function buildSignatureHeader(\n secret: string,\n timestamp: number,\n rawBody: string,\n): string {\n const v1 = signV1(secret, timestamp, rawBody);\n return `t=${timestamp},${SIGNATURE_VERSION}=${v1}`;\n}\n\n/**\n * Parse a `X-Nest-Batch-Signature` header value back into its\n * parts. Used by the test server to extract the `t=` and\n * `v1=` fields for byte-equality verification.\n *\n * Throws on malformed input. Does NOT verify the HMAC; the\n * caller is expected to call `verifyV1` with the original body.\n */\nexport interface ParsedSignature {\n readonly timestamp: number;\n readonly v1: string;\n}\n\nexport function parseSignatureHeader(header: string): ParsedSignature {\n if (typeof header !== 'string' || header.length === 0) {\n throw new Error('[webhook-signing] header is empty');\n }\n const parts = header.split(',').map((p) => p.trim());\n let timestamp: number | undefined;\n let v1: string | undefined;\n for (const part of parts) {\n if (part.startsWith('t=')) {\n const raw = part.slice(2);\n timestamp = Number.parseInt(raw, 10);\n if (!Number.isFinite(timestamp) || timestamp < 0) {\n throw new Error(`[webhook-signing] invalid t= value: ${raw}`);\n }\n } else if (part.startsWith(`${SIGNATURE_VERSION}=`)) {\n v1 = part.slice(SIGNATURE_VERSION.length + 1);\n if (v1.length === 0) {\n throw new Error(`[webhook-signing] empty ${SIGNATURE_VERSION}= value`);\n }\n }\n }\n if (timestamp === undefined || v1 === undefined) {\n throw new Error(\n `[webhook-signing] header missing t= or ${SIGNATURE_VERSION}= field: ${header}`,\n );\n }\n return { timestamp, v1 };\n}\n\n/**\n * Timing-safe verification of a `X-Nest-Batch-Signature` header\n * against a (secret, raw body) pair.\n *\n * Returns `true` iff the v1 HMAC matches. Uses `timingSafeEqual`\n * to prevent timing-leak attacks on the comparison.\n *\n * Note: this helper is the SYMMETRIC counterpart of `signV1`.\n * Receivers (the URL targets) call it after extracting the\n * header value via `parseSignatureHeader`. The test suite uses\n * it to assert byte-equality of the HMAC computed by the\n * observer against the HMAC computed independently with the\n * same secret + body.\n */\nexport function verifyV1(\n secret: string,\n timestamp: number,\n rawBody: string,\n candidateV1: string,\n): boolean {\n const expected = signV1(secret, timestamp, rawBody);\n // Both `expected` and `candidateV1` are lowercase hex of the\n // same length for a given (secret, body) pair, so the equal-\n // length precondition of `timingSafeEqual` holds. Defensive\n // length check: if the candidate is the wrong length, return\n // false without invoking the constant-time compare (the\n // length itself is not a secret).\n if (candidateV1.length !== expected.length) return false;\n try {\n return timingSafeEqual(\n Buffer.from(expected, 'hex'),\n Buffer.from(candidateV1, 'hex'),\n );\n } catch {\n return false;\n }\n}\n\n/**\n * Compute a SHA-256 fingerprint of the secret for use in\n * dead-letter log lines. The host NEVER wants the secret (or a\n * substring of it) in a log line, but operators often want a\n * stable identifier to correlate dead-letter lines across\n * services (\"all 4xx dead-letters today used secret_sha256=abc...\").\n *\n * Returns the first 12 hex chars of `sha256(secret)` — enough\n * to be useful as a correlation tag, short enough that it\n * cannot be brute-forced back to the secret.\n */\nexport function fingerprintSecret(secret: string): string {\n if (typeof secret !== 'string' || secret.length === 0) {\n return '<missing>';\n }\n return createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 12);\n}\n\n/**\n * The literal header name. Re-exported so the test server and\n * the observer never have to repeat the magic string.\n */\nexport const SIGNATURE_HEADER_NAME = SIGNATURE_HEADER;\n"],"names":["SIGNATURE_HEADER_NAME","buildSignatureHeader","fingerprintSecret","parseSignatureHeader","signV1","verifyV1","SIGNATURE_HEADER","SIGNATURE_VERSION","secret","timestamp","rawBody","length","Error","Number","isFinite","hmac","createHmac","update","digest","v1","header","parts","split","map","p","trim","part","startsWith","raw","slice","parseInt","undefined","candidateV1","expected","timingSafeEqual","Buffer","from","createHash"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nest-batch/webhook",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Webhook delivery observer for @nest-batch/core. Subscribes to BATCH_EVENT.* and POSTs HMAC-SHA256-signed JSON envelopes to one or more URLs with exponential-backoff retry and dead-letter logging. Uses native fetch (Node 20+); no HTTP client peer dep.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "easdkr",
|
|
7
|
+
"homepage": "https://github.com/easdkr/nest-batch/tree/main/packages/webhook#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/easdkr/nest-batch.git",
|
|
11
|
+
"directory": "packages/webhook"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/easdkr/nest-batch/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"nestjs",
|
|
18
|
+
"batch",
|
|
19
|
+
"webhook",
|
|
20
|
+
"hmac",
|
|
21
|
+
"hmac-sha256",
|
|
22
|
+
"observer",
|
|
23
|
+
"dead-letter"
|
|
24
|
+
],
|
|
25
|
+
"main": "dist/src/index.js",
|
|
26
|
+
"types": "dist/src/index.d.ts",
|
|
27
|
+
"files": [
|
|
28
|
+
"dist/src",
|
|
29
|
+
"src",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@nestjs/common": "^10 || ^11",
|
|
37
|
+
"@nestjs/core": "^10 || ^11",
|
|
38
|
+
"@nest-batch/core": "^0.2.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"@nest-batch/core": {
|
|
42
|
+
"optional": false
|
|
43
|
+
},
|
|
44
|
+
"@nestjs/common": {
|
|
45
|
+
"optional": false
|
|
46
|
+
},
|
|
47
|
+
"@nestjs/core": {
|
|
48
|
+
"optional": false
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@nestjs/common": "^11.0.0",
|
|
53
|
+
"@nestjs/core": "^11.0.0",
|
|
54
|
+
"@swc/cli": "^0.7.0",
|
|
55
|
+
"@swc/core": "^1.10.7",
|
|
56
|
+
"@types/node": "^22.0.0",
|
|
57
|
+
"reflect-metadata": "^0.2.2",
|
|
58
|
+
"typescript": "^5.5.0",
|
|
59
|
+
"unplugin-swc": "^1.5.0",
|
|
60
|
+
"vitest": "^2.0.0",
|
|
61
|
+
"@nest-batch/core": "0.2.0"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "swc src -d dist --config-file ../../.swcrc && tsc --emitDeclarationOnly -p tsconfig.build.json",
|
|
65
|
+
"test": "vitest run",
|
|
66
|
+
"test:watch": "vitest",
|
|
67
|
+
"typecheck": "tsc --noEmit"
|
|
68
|
+
}
|
|
69
|
+
}
|