@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.
@@ -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":";;;;;;;;;;;QAuZSA;eAAAA,iBAAW;;QAxWPC;eAAAA;;;wBA/C8B;sBAMpC;+BAQA;gCAKA;;;;;;;;;;;;;;;AA4BA,IAAA,AAAMA,uBAAN,MAAMA;IACMC,OAAsB;IAEvC,uEAAuE,GACvE,AAAiBC,QAAgC;IAEjD;;;GAGC,GACD,AAAiBC,WAAwC;IAEzD;;;;;;;GAOC,GACD,AAAiBC,cAAiC;IAElD;;;;GAIC,GACD,YACE,AAAgCF,OAA+B,CAC/D;QACA,IAAI,CAACA,OAAO,GAAGA;QACf,IAAI,CAACD,MAAM,GAAGC,QAAQD,MAAM,IAAI,IAAII,cAAM,CAACL,qBAAqBM,IAAI;QACpE,IAAI,CAACH,UAAU,GAAG,IAAII,IAAIL,QAAQM,MAAM;QACxC,IAAI,CAACJ,aAAa,GAChBK,QAAQC,GAAG,CAAC,oBAAoB,KAAK,MACjCC,2CAA4B,GAC5BC,8CAA+B;IACvC;IAEA;;;;;;GAMC,GACD,MAAMC,QAAQC,KAAiB,EAAiB;QAC9C,IAAI,CAAC,IAAI,CAACX,UAAU,CAACY,GAAG,CAACD,MAAME,IAAI,GAAG;QACtC,IAAI,IAAI,CAACd,OAAO,CAACe,IAAI,CAACC,MAAM,KAAK,GAAG;QACpC,IAAI;YACF,MAAM,IAAI,CAACC,YAAY,CAACL;QAC1B,EAAE,OAAOM,KAAK;YACZ,qDAAqD;YACrD,wDAAwD;YACxD,kDAAkD;YAClD,mDAAmD;YACnD,oDAAoD;YACpD,kDAAkD;YAClD,mDAAmD;YACnD,iDAAiD;YACjD,WAAW;YACX,IAAI,CAACnB,MAAM,CAACoB,IAAI,CACd,CAAC,+BAA+B,EAAEP,MAAME,IAAI,CAAC,CAAC,CAAC,GAC7C,CAAC,eAAe,EAAEF,MAAMQ,cAAc,CAAC,EAAE,CAAC,GAC1C,GAAGF,eAAeG,QAAQH,IAAII,OAAO,GAAGC,OAAOL,MAAM;QAE3D;IACF;IAEA,0EAA0E;IAC1E,UAAU;IACV,0EAA0E;IAE1E;;;;;;;;;;;GAWC,GACD,MAAcD,aAAaL,KAAiB,EAAiB;QAC3D,MAAMY,WAAW,IAAI,CAACC,aAAa,CAACb;QACpC,MAAMc,OAAOC,KAAKC,SAAS,CAACJ;QAC5B,MAAMK,QAAQC,GAAG,CACf,IAAI,CAAC9B,OAAO,CAACe,IAAI,CAACgB,GAAG,CAAC,CAACC,MAAQ,IAAI,CAACC,YAAY,CAACD,KAAKpB,OAAOc;IAEjE;IAEA;;;;;;;;;;;;;;;;;;;;;;;GAuBC,GACD,AAAQD,cAAcb,KAAiB,EAAmB;QACxD,OAAO;YACLsB,SAAS;YACTpB,MAAMF,MAAME,IAAI;YAChBqB,WAAWvB,MAAMuB,SAAS,CAACC,WAAW;YACtCC,OAAOzB,MAAMQ,cAAc;YAC3B,GAAIR,MAAM0B,eAAe,KAAKC,YAC1B;gBAAEC,QAAQ5B,MAAM0B,eAAe;YAAC,IAChC,CAAC,CAAC;YACNG,WAAWC,UAAU9B,MAAM+B,IAAI;QACjC;IACF;IAEA,0EAA0E;IAC1E,8BAA8B;IAC9B,0EAA0E;IAE1E;;;;;;GAMC,GACD,MAAcV,aACZD,GAAW,EACXpB,KAAiB,EACjBc,IAAY,EACG;QACf,MAAMS,YAAYS,KAAKC,KAAK,CAACC,KAAKC,GAAG,KAAK;QAC1C,MAAMC,YAAYC,IAAAA,oCAAoB,EACpC,IAAI,CAACjD,OAAO,CAACkD,MAAM,EACnBf,WACAT;QAEF,MAAMyB,cAAcC,IAAAA,iCAAiB,EAAC,IAAI,CAACpD,OAAO,CAACkD,MAAM;QAEzD,MAAMG,gBAAgB,IAAI,CAACrD,OAAO,CAACsD,QAAQ;QAC3C,IAAIC;QACJ,IAAIC;QAEJ,IAAK,IAAIC,UAAU,GAAGA,WAAWJ,eAAeI,UAAW;YACzD,MAAMC,SAAS,MAAM,IAAI,CAACC,WAAW,CAAC3B,KAAKN,MAAMsB,WAAWb;YAC5D,IAAIuB,OAAOE,IAAI,KAAK,WAAW;gBAC7B,IAAIH,UAAU,GAAG;oBACf,IAAI,CAAC1D,MAAM,CAAC8D,GAAG,CACb,CAAC,cAAc,EAAE7B,IAAI,MAAM,EAAEpB,MAAME,IAAI,CAAC,CAAC,CAAC,GACxC,CAAC,eAAe,EAAEF,MAAMQ,cAAc,CAAC,SAAS,EAAEqC,QAAQ,CAAC,EAAEJ,cAAc,CAAC,CAAC,GAC7E,CAAC,OAAO,EAAEK,OAAOI,MAAM,CAAC,cAAc,CAAC;gBAE7C,OAAO;oBACL,IAAI,CAAC/D,MAAM,CAACgE,KAAK,CACf,CAAC,cAAc,EAAE/B,IAAI,MAAM,EAAEpB,MAAME,IAAI,CAAC,CAAC,CAAC,GACxC,CAAC,eAAe,EAAEF,MAAMQ,cAAc,CAAC,SAAS,EAAEqC,QAAQ,CAAC,EAAEJ,cAAc,CAAC,CAAC,GAC7E,CAAC,OAAO,EAAEK,OAAOI,MAAM,EAAE;gBAE/B;gBACA;YACF;YACA,IAAIJ,OAAOE,IAAI,KAAK,gBAAgB;gBAClC,qDAAqD;gBACrD,oDAAoD;gBACpD,kDAAkD;gBAClD,oDAAoD;gBACpD,mDAAmD;gBACnD,IAAI,CAAC7D,MAAM,CAACoB,IAAI,CACd,CAAC,uCAAuC,EAAEa,IAAI,UAAU,EAAEyB,QAAQ,CAAC,CAAC,GAClE,CAAC,WAAW,EAAEC,OAAOI,MAAM,CAAC,gBAAgB,EAAEJ,OAAOI,MAAM,CAAC,CAAC,CAAC,GAC9D,CAAC,KAAK,EAAElD,MAAME,IAAI,CAAC,gBAAgB,EAAEF,MAAMQ,cAAc,CAAC,CAAC,CAAC,GAC5D,CAAC,cAAc,EAAE+B,aAAa;gBAElC;YACF;YACA,mDAAmD;YACnDI,aAAaG,OAAOE,IAAI,KAAK,iBAAiBF,OAAOI,MAAM,GAAGvB;YAC9DiB,YAAYE,OAAOE,IAAI,KAAK,iBACxB,CAAC,KAAK,EAAEF,OAAOI,MAAM,EAAE,GACvBJ,OAAOM,KAAK;YAEhB,IAAIP,UAAUJ,eAAe;gBAC3B,gDAAgD;gBAChD,mDAAmD;gBACnD,iDAAiD;gBACjD,yCAAyC;gBACzC,MAAMY,aAAarB,KAAKsB,GAAG,CAACT,UAAU,GAAG,IAAI,CAACvD,aAAa,CAACc,MAAM,GAAG;gBACrE,MAAMmD,UAAU,IAAI,CAACjE,aAAa,CAAC+D,WAAW,IAAI;gBAClD,IAAI,CAAClE,MAAM,CAACgE,KAAK,CACf,CAAC,UAAU,EAAE/B,IAAI,SAAS,EAAEyB,QAAQ,CAAC,EAAEJ,cAAc,CAAC,CAAC,GACrD,CAAC,OAAO,EAAEE,cAAc,MAAM,WAAW,EAAEC,UAAU,CAAC,CAAC,GACvD,CAAC,YAAY,EAAEW,SAAS;gBAE5B,MAAMC,MAAMD;YACd;QACF;QAEA,qDAAqD;QACrD,sDAAsD;QACtD,0DAA0D;QAC1D,+CAA+C;QAC/C,IAAI,CAACpE,MAAM,CAACoB,IAAI,CACd,CAAC,uCAAuC,EAAEa,IAAI,UAAU,EAAEqB,cAAc,CAAC,CAAC,GACxE,CAAC,WAAW,EAAEE,cAAc,MAAM,WAAW,EAAEC,aAAa,MAAM,CAAC,CAAC,GACpE,CAAC,KAAK,EAAE5C,MAAME,IAAI,CAAC,gBAAgB,EAAEF,MAAMQ,cAAc,CAAC,CAAC,CAAC,GAC5D,CAAC,cAAc,EAAE+B,aAAa;IAEpC;IAEA;;;;;;;;;;;;;;GAcC,GACD,MAAcQ,YACZ3B,GAAW,EACXN,IAAY,EACZsB,SAAiB,EACjBb,SAAiB,EAMjB;QACA,MAAMkC,aAAa,IAAIC;QACvB,MAAMC,QAAQC,WAAW,IAAMH,WAAWI,KAAK,IAAI,IAAI,CAACzE,OAAO,CAAC0E,SAAS;QACzE,IAAI;YACF,MAAMC,WAAW,MAAMC,MAAM5C,KAAK;gBAChC6C,QAAQ;gBACRC,SAAS;oBACP,gBAAgB;oBAChB,CAACC,qCAAqB,CAAC,EAAE/B;oBACzB,0BAA0BzB,OAAOY;gBACnC;gBACAT;gBACAsD,QAAQX,WAAWW,MAAM;gBACzB,8CAA8C;gBAC9C,+CAA+C;gBAC/C,qCAAqC;gBACrC,kCAAkC;gBAClCC,UAAU;YACZ;YACA,MAAMnB,SAASa,SAASb,MAAM;YAC9B,IAAIA,UAAU,OAAOA,SAAS,KAAK;gBACjC,OAAO;oBAAEF,MAAM;oBAAWE;gBAAO;YACnC;YACA,IAAIA,UAAU,OAAOA,SAAS,KAAK;gBACjC,OAAO;oBAAEF,MAAM;oBAAgBE;gBAAO;YACxC;YACA,mDAAmD;YACnD,mDAAmD;YACnD,4CAA4C;YAC5C,0BAA0B;YAC1B,OAAO;gBAAEF,MAAM;gBAAgBE;YAAO;QACxC,EAAE,OAAO5C,KAAK;YACZ,kDAAkD;YAClD,oDAAoD;YACpD,YAAY;YACZ,MAAMI,UAAUJ,eAAeG,QAAQH,IAAII,OAAO,GAAGC,OAAOL;YAC5D,OAAO;gBAAE0C,MAAM;gBAAiBI,OAAO1C;YAAQ;QACjD,SAAU;YACR4D,aAAaX;QACf;IACF;AACF;;;;;;;;;AAEA,0EAA0E;AAC1E,mBAAmB;AACnB,0EAA0E;AAE1E;;;;;;CAMC,GACD,SAASH,MAAMe,EAAU;IACvB,IAAIA,MAAM,GAAG,OAAOtD,QAAQuD,OAAO;IACnC,OAAO,IAAIvD,QAAQ,CAACuD;QAClBZ,WAAWY,SAASD;IACtB;AACF;AAEA;;;;;;;CAOC,GACD,SAASzC,UAAa2C,KAAQ;IAC5B,IAAIA,UAAU,QAAQ,OAAOA,UAAU,UAAU,OAAOA;IACxD,oDAAoD;IACpD,qDAAqD;IACrD,oDAAoD;IACpD,oBAAoB;IACpB,IAAI;QACF,OAAOC,gBAAgBD;IACzB,EAAE,OAAM;QACN,OAAOA;IACT;AACF"}
@@ -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
+ }