@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,408 @@
1
+ import { Inject, Injectable, Logger } from '@nestjs/common';
2
+ import {
3
+ BATCH_EVENT,
4
+ type BatchEvent,
5
+ type BatchEventType,
6
+ type BatchObserver,
7
+ } from '@nest-batch/core';
8
+
9
+ import {
10
+ DEFAULT_WEBHOOK_RETRY_DELAYS_MS,
11
+ FAST_WEBHOOK_RETRY_DELAYS_MS,
12
+ WEBHOOK_MODULE_OPTIONS,
13
+ type ResolvedWebhookOptions,
14
+ type WebhookLogger,
15
+ } from './module-options';
16
+ import {
17
+ SIGNATURE_HEADER_NAME,
18
+ buildSignatureHeader,
19
+ fingerprintSecret,
20
+ } from './webhook-signing';
21
+
22
+ /**
23
+ * `WebhookBatchObserver` — the v1 webhook delivery observer.
24
+ *
25
+ * Implements `BatchObserver` from `@nest-batch/core`. On every
26
+ * subscribed `BATCH_EVENT.*` (default:
27
+ * `[JOB_COMPLETED, JOB_FAILED, STEP_FAILED]`) the observer:
28
+ *
29
+ * 1. Serializes a normalized JSON envelope
30
+ * `{ version: 1, type, timestamp, jobId, execution }`.
31
+ * 2. Computes the v1 HMAC-SHA256 signature over
32
+ * `<unix>.<raw-body>` (Stripe-style).
33
+ * 3. POSTs the envelope + `X-Nest-Batch-Signature` header to
34
+ * every URL in `urls`.
35
+ * 4. Retries on 5xx and network errors through the fixed
36
+ * 4-attempt budget at `[1s, 5s, 25s, 125s]`. HTTP 4xx
37
+ * responses are NOT retried (client error, won't change).
38
+ * 5. On final failure, emits a `logger.warn` dead-letter line
39
+ * including the URL, attempt count, last status / error,
40
+ * and a SHA-256 fingerprint of the secret (NEVER the
41
+ * secret itself).
42
+ *
43
+ * The observer is the v1 contract documented in
44
+ * `docs/RELEASE-0.2.0.md` §7 and pinned by T-AC-5
45
+ * (`packages/webhook/tests/webhook-observer.test.ts`).
46
+ */
47
+ @Injectable()
48
+ export class WebhookBatchObserver implements BatchObserver {
49
+ private readonly logger: WebhookLogger;
50
+
51
+ /** Resolved + frozen options. The secret lives here and nowhere else. */
52
+ private readonly options: ResolvedWebhookOptions;
53
+
54
+ /**
55
+ * Cached lookup of the subscription set. Built once at
56
+ * construction time so `onEvent` is a single `Set.has` check.
57
+ */
58
+ private readonly subscribed: ReadonlySet<BatchEventType>;
59
+
60
+ /**
61
+ * Test-only override for the retry schedule. When
62
+ * `process.env.WEBHOOK_TEST_FAST === '1'`, the schedule is
63
+ * `[1ms, 5ms, 25ms, 125ms]` so the suite can exercise the
64
+ * 4-attempt path without waiting 156 seconds. The override
65
+ * is gated behind an env var so production cannot trip it
66
+ * by accident.
67
+ */
68
+ private readonly retryDelaysMs: readonly number[];
69
+
70
+ /**
71
+ * Sentinel subscriber set: defaults to
72
+ * `[JOB_COMPLETED, JOB_FAILED, STEP_FAILED]`. Overridable via
73
+ * the `events` option in `forRoot({...})`.
74
+ */
75
+ constructor(
76
+ @Inject(WEBHOOK_MODULE_OPTIONS) options: ResolvedWebhookOptions,
77
+ ) {
78
+ this.options = options;
79
+ this.logger = options.logger ?? new Logger(WebhookBatchObserver.name);
80
+ this.subscribed = new Set(options.events);
81
+ this.retryDelaysMs =
82
+ process.env['WEBHOOK_TEST_FAST'] === '1'
83
+ ? FAST_WEBHOOK_RETRY_DELAYS_MS
84
+ : DEFAULT_WEBHOOK_RETRY_DELAYS_MS;
85
+ }
86
+
87
+ /**
88
+ * `BatchObserver` entry point. Filters by the subscription
89
+ * set, then dispatches to every URL. NEVER throws — a slow /
90
+ * failing observer must not poison the executor (the
91
+ * JobExecutor already swallows observer errors, but we are
92
+ * defensive in depth).
93
+ */
94
+ async onEvent(event: BatchEvent): Promise<void> {
95
+ if (!this.subscribed.has(event.type)) return;
96
+ if (this.options.urls.length === 0) return;
97
+ try {
98
+ await this.deliverToAll(event);
99
+ } catch (err) {
100
+ // Defence in depth: the JobExecutor already swallows
101
+ // observer errors, but we re-assert it here so a single
102
+ // failing URL cannot starve the rest. The per-URL
103
+ // delivery loop has its own try/catch and writes a
104
+ // dead-letter `warn` for fully-failed URLs, so this
105
+ // outer catch only fires for genuinely unexpected
106
+ // errors (e.g. a synchronous throw in the envelope
107
+ // builder). The secret is NEVER included in this
108
+ // message.
109
+ this.logger.warn(
110
+ `unexpected observer error type=${event.type} ` +
111
+ `jobExecutionId=${event.jobExecutionId}: ` +
112
+ `${err instanceof Error ? err.message : String(err)}`,
113
+ );
114
+ }
115
+ }
116
+
117
+ // -----------------------------------------------------------------------
118
+ // Fan-out
119
+ // -----------------------------------------------------------------------
120
+
121
+ /**
122
+ * Build the envelope once, then POST to every URL in
123
+ * `urls` in parallel. A single URL's retry exhaustion does
124
+ * not affect the other URLs — each URL has its own
125
+ * `deliverToUrl` invocation and its own dead-letter line.
126
+ *
127
+ * The envelope is built with `JSON.stringify` (NOT a Nest
128
+ * serializer) so the bytes are stable and match the HMAC
129
+ * input byte-for-byte. The body string is the literal
130
+ * argument to `fetch`, so the receiver sees the same
131
+ * bytes the observer signed.
132
+ */
133
+ private async deliverToAll(event: BatchEvent): Promise<void> {
134
+ const envelope = this.buildEnvelope(event);
135
+ const body = JSON.stringify(envelope);
136
+ await Promise.all(
137
+ this.options.urls.map((url) => this.deliverToUrl(url, event, body)),
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Build the v1 envelope payload. The shape is the contract
143
+ * the receiver expects; changing it is a breaking change.
144
+ *
145
+ * - `version: 1` — the envelope schema version (the
146
+ * `v1=` in the signature header is the SIGNATURE
147
+ * version, not the ENVELOPE version; they are
148
+ * independent).
149
+ * - `type` — the `BatchEvent.type` string verbatim
150
+ * (e.g. `nest-batch.job.completed`).
151
+ * - `timestamp` — the event's `Date` serialized as
152
+ * ISO-8601 (the original `Date` is not JSON-safe).
153
+ * - `jobId` — the `jobExecutionId` (the `BatchEvent`
154
+ * contract guarantees this is always set).
155
+ * - `execution` — the `JobExecution` shape derived from
156
+ * the event's `data` payload. The observer treats
157
+ * `data` as opaque `JsonValue` and passes it through
158
+ * after a defensive deep-copy via `structuredClone`
159
+ * so the observer cannot mutate the executor's
160
+ * internal state by reference.
161
+ * - `stepId` — present for STEP\_\* / CHUNK\_\* / ITEM\_\*
162
+ * events; absent for JOB\_\* events. Mirrors the
163
+ * `BatchEvent.stepExecutionId` contract.
164
+ */
165
+ private buildEnvelope(event: BatchEvent): WebhookEnvelope {
166
+ return {
167
+ version: 1,
168
+ type: event.type,
169
+ timestamp: event.timestamp.toISOString(),
170
+ jobId: event.jobExecutionId,
171
+ ...(event.stepExecutionId !== undefined
172
+ ? { stepId: event.stepExecutionId }
173
+ : {}),
174
+ execution: cloneJson(event.data),
175
+ };
176
+ }
177
+
178
+ // -----------------------------------------------------------------------
179
+ // Per-URL delivery with retry
180
+ // -----------------------------------------------------------------------
181
+
182
+ /**
183
+ * POST the envelope to one URL with the full retry budget.
184
+ * Stops on the first 2xx; retries on 5xx and network errors;
185
+ * does NOT retry on 4xx; emits a dead-letter `warn` on
186
+ * exhaustion. The body is signed once; the same signed body
187
+ * is sent on every attempt.
188
+ */
189
+ private async deliverToUrl(
190
+ url: string,
191
+ event: BatchEvent,
192
+ body: string,
193
+ ): Promise<void> {
194
+ const timestamp = Math.floor(Date.now() / 1000);
195
+ const signature = buildSignatureHeader(
196
+ this.options.secret,
197
+ timestamp,
198
+ body,
199
+ );
200
+ const fingerprint = fingerprintSecret(this.options.secret);
201
+
202
+ const totalAttempts = this.options.attempts;
203
+ let lastStatus: number | undefined;
204
+ let lastError: string | undefined;
205
+
206
+ for (let attempt = 1; attempt <= totalAttempts; attempt++) {
207
+ const result = await this.attemptOnce(url, body, signature, timestamp);
208
+ if (result.kind === 'success') {
209
+ if (attempt > 1) {
210
+ this.logger.log(
211
+ `delivered url=${url} type=${event.type} ` +
212
+ `jobExecutionId=${event.jobExecutionId} attempt=${attempt}/${totalAttempts} ` +
213
+ `status=${result.status} (after retry)`,
214
+ );
215
+ } else {
216
+ this.logger.debug(
217
+ `delivered url=${url} type=${event.type} ` +
218
+ `jobExecutionId=${event.jobExecutionId} attempt=${attempt}/${totalAttempts} ` +
219
+ `status=${result.status}`,
220
+ );
221
+ }
222
+ return;
223
+ }
224
+ if (result.kind === 'client-error') {
225
+ // 4xx — NO retry. Log at `warn` and return. The host
226
+ // is expected to fix the misconfiguration (bad URL,
227
+ // missing auth, malformed payload). The signature
228
+ // fingerprint and the attempt count are included so
229
+ // the host can correlate with the receiver's logs.
230
+ this.logger.warn(
231
+ `[WebhookBatchObserver] dead-letter url=${url} attempts=${attempt} ` +
232
+ `lastStatus=${result.status} lastError=HTTP ${result.status} ` +
233
+ `type=${event.type} jobExecutionId=${event.jobExecutionId} ` +
234
+ `secret_sha256=${fingerprint}`,
235
+ );
236
+ return;
237
+ }
238
+ // result.kind === 'server-error' | 'network-error'
239
+ lastStatus = result.kind === 'server-error' ? result.status : undefined;
240
+ lastError = result.kind === 'server-error'
241
+ ? `HTTP ${result.status}`
242
+ : result.error;
243
+
244
+ if (attempt < totalAttempts) {
245
+ // The retry schedule has exactly `attempts - 1`
246
+ // entries (delays BETWEEN attempts). When attempts
247
+ // is < 4 (test override), the array is sliced to
248
+ // match — we do not extend the schedule.
249
+ const delayIndex = Math.min(attempt - 1, this.retryDelaysMs.length - 1);
250
+ const delayMs = this.retryDelaysMs[delayIndex] ?? 0;
251
+ this.logger.debug(
252
+ `retry url=${url} attempt=${attempt}/${totalAttempts} ` +
253
+ `status=${lastStatus ?? 'n/a'} lastError=${lastError} ` +
254
+ `nextDelayMs=${delayMs}`,
255
+ );
256
+ await sleep(delayMs);
257
+ }
258
+ }
259
+
260
+ // Final failure — log dead-letter. NEVER include the
261
+ // secret. The fingerprint is a SHA-256 prefix (12 hex
262
+ // chars) that operators can use to correlate dead-letters
263
+ // across services without exposing the secret.
264
+ this.logger.warn(
265
+ `[WebhookBatchObserver] dead-letter url=${url} attempts=${totalAttempts} ` +
266
+ `lastStatus=${lastStatus ?? 'n/a'} lastError=${lastError ?? 'n/a'} ` +
267
+ `type=${event.type} jobExecutionId=${event.jobExecutionId} ` +
268
+ `secret_sha256=${fingerprint}`,
269
+ );
270
+ }
271
+
272
+ /**
273
+ * Single POST attempt. The signature header is sent on
274
+ * every attempt (the body bytes are identical across
275
+ * attempts; the receiver can verify the signature against
276
+ * any of them).
277
+ *
278
+ * The result is a discriminated union:
279
+ * - `kind: 'success'` — 2xx (or 3xx; we follow
280
+ * the redirect by default in `fetch`, but the
281
+ * receiver's terminal status is what we report)
282
+ * - `kind: 'client-error'` — 4xx (no retry)
283
+ * - `kind: 'server-error'` — 5xx (retry)
284
+ * - `kind: 'network-error'` — fetch threw, or
285
+ * `AbortError` from the timeout (retry)
286
+ */
287
+ private async attemptOnce(
288
+ url: string,
289
+ body: string,
290
+ signature: string,
291
+ timestamp: number,
292
+ ): Promise<
293
+ | { kind: 'success'; status: number }
294
+ | { kind: 'client-error'; status: number }
295
+ | { kind: 'server-error'; status: number }
296
+ | { kind: 'network-error'; error: string }
297
+ > {
298
+ const controller = new AbortController();
299
+ const timer = setTimeout(() => controller.abort(), this.options.timeoutMs);
300
+ try {
301
+ const response = await fetch(url, {
302
+ method: 'POST',
303
+ headers: {
304
+ 'content-type': 'application/json',
305
+ [SIGNATURE_HEADER_NAME]: signature,
306
+ 'x-nest-batch-timestamp': String(timestamp),
307
+ },
308
+ body,
309
+ signal: controller.signal,
310
+ // fetch follows 3xx redirects by default; the
311
+ // redirect target's terminal status drives the
312
+ // retry decision per the v1 contract
313
+ // (`docs/RELEASE-0.2.0.md` §7.3).
314
+ redirect: 'follow',
315
+ });
316
+ const status = response.status;
317
+ if (status >= 200 && status < 300) {
318
+ return { kind: 'success', status };
319
+ }
320
+ if (status >= 400 && status < 500) {
321
+ return { kind: 'client-error', status };
322
+ }
323
+ // 5xx (and 3xx that somehow slipped past redirect:
324
+ // should not happen with `redirect: 'follow'`, but
325
+ // be defensive). Treat anything >= 500 as a
326
+ // server-error and retry.
327
+ return { kind: 'server-error', status };
328
+ } catch (err) {
329
+ // Network error, DNS failure, AbortError from the
330
+ // timeout, etc. All treated as retryable per the v1
331
+ // contract.
332
+ const message = err instanceof Error ? err.message : String(err);
333
+ return { kind: 'network-error', error: message };
334
+ } finally {
335
+ clearTimeout(timer);
336
+ }
337
+ }
338
+ }
339
+
340
+ // -----------------------------------------------------------------------
341
+ // Internal helpers
342
+ // -----------------------------------------------------------------------
343
+
344
+ /**
345
+ * Sleep for `ms` milliseconds. Returns a promise that
346
+ * resolves with no value. Used between retry attempts. The
347
+ * `ms <= 0` short-circuit keeps the fast-mode test
348
+ * schedule (`1ms`, `5ms`, ...) from producing timer
349
+ * warnings.
350
+ */
351
+ function sleep(ms: number): Promise<void> {
352
+ if (ms <= 0) return Promise.resolve();
353
+ return new Promise((resolve) => {
354
+ setTimeout(resolve, ms);
355
+ });
356
+ }
357
+
358
+ /**
359
+ * Defensive deep-clone of an arbitrary `JsonValue`. The
360
+ * `BatchEvent.data` field is typed as `JsonValue` and is
361
+ * shared with the executor's internal state; we clone it
362
+ * so the observer cannot mutate the executor by reference.
363
+ * `structuredClone` is available in Node 17+ and is the
364
+ * fastest safe deep-clone for JSON-shaped data.
365
+ */
366
+ function cloneJson<T>(value: T): T {
367
+ if (value === null || typeof value !== 'object') return value;
368
+ // structuredClone can throw on non-cloneable values
369
+ // (e.g. functions, symbols). The BatchEvent contract
370
+ // guarantees `data` is `JsonValue`, so the catch is
371
+ // purely defensive.
372
+ try {
373
+ return structuredClone(value) as T;
374
+ } catch {
375
+ return value;
376
+ }
377
+ }
378
+
379
+ // -----------------------------------------------------------------------
380
+ // Public types
381
+ // -----------------------------------------------------------------------
382
+
383
+ /**
384
+ * The v1 webhook envelope payload. This is the contract the
385
+ * receiver's parser expects. Fields are stable; new fields
386
+ * are additive only and use the `x-` prefix to mark them
387
+ * as out-of-contract for v1.
388
+ */
389
+ export interface WebhookEnvelope {
390
+ /** Envelope schema version. Always `1` for v1. */
391
+ readonly version: 1;
392
+ /** The `BatchEvent.type` string (e.g. `nest-batch.job.completed`). */
393
+ readonly type: BatchEventType;
394
+ /** Event timestamp as ISO-8601. */
395
+ readonly timestamp: string;
396
+ /** The `JobExecution.id` (a.k.a. `jobExecutionId`). */
397
+ readonly jobId: string;
398
+ /** The `StepExecution.id` (a.k.a. `stepExecutionId`). STEP\_\* / CHUNK\_\* / ITEM\_\* events only. */
399
+ readonly stepId?: string;
400
+ /** The `BatchEvent.data` payload, deep-cloned for safety. */
401
+ readonly execution: unknown;
402
+ }
403
+
404
+ // Re-export the `BATCH_EVENT` constant so consumers can
405
+ // reference the exact subscription set without having to
406
+ // import `@nest-batch/core` themselves. The names of the
407
+ // event types are part of the public surface.
408
+ export { BATCH_EVENT };
@@ -0,0 +1,185 @@
1
+ import { createHmac, createHash, timingSafeEqual } from 'node:crypto';
2
+
3
+ /**
4
+ * HMAC-SHA256 signing helper for the outbound webhook envelope.
5
+ *
6
+ * The signature is shipped in the `X-Nest-Batch-Signature` header
7
+ * with the Stripe-style `t=<unix>,v1=<hex>` shape:
8
+ *
9
+ * X-Nest-Batch-Signature: t=1717941612,v1=4f3a2b...c1d
10
+ *
11
+ * Where:
12
+ * - `t` is the unix-seconds timestamp the receiver should use
13
+ * to enforce a replay window (recommended: 5 minutes).
14
+ * - `v1` is the lowercase hex of
15
+ * `HMAC_SHA256(secret, "<unix>.<raw-body>")`.
16
+ *
17
+ * The `<raw-body>` is the EXACT JSON-serialized request body bytes
18
+ * (not a re-serialization). Callers must pass the same string
19
+ * they POST — the helper does not re-serialize. This avoids the
20
+ * classic "server signed stringified JSON, client re-stringified
21
+ * with different key order" footgun.
22
+ *
23
+ * The `v1` key is the v1 contract; a future v2 may add
24
+ * `v2=`-prefixed scheme-version constants (e.g. a SHA-512
25
+ * variant). Receivers MUST reject unknown `vN` keys.
26
+ *
27
+ * Reference: `docs/RELEASE-0.2.0.md` §7.4.
28
+ */
29
+
30
+ const SIGNATURE_HEADER = 'X-Nest-Batch-Signature';
31
+ const SIGNATURE_VERSION = 'v1';
32
+
33
+ /**
34
+ * Compute the v1 HMAC-SHA256 signature for the given (timestamp,
35
+ * raw body) pair.
36
+ *
37
+ * Returns the lowercase hex string the receiver compares against
38
+ * the `v1=` field. The function is timing-safe on the input
39
+ * (Node's `createHmac` is constant-time per the crypto spec), so
40
+ * it is safe to use for verification as well.
41
+ *
42
+ * @param secret The host-injected secret. Never logged, never
43
+ * serialized, never returned by the helper.
44
+ * @param timestamp The unix-seconds integer the signature is
45
+ * pinned to. Must be a positive integer; the helper does not
46
+ * validate the value (callers may pin it to `Math.floor(Date.now() / 1000)`).
47
+ * @param rawBody The exact JSON-serialized body bytes the
48
+ * request will POST. Must match the body the receiver HMACs.
49
+ */
50
+ export function signV1(
51
+ secret: string,
52
+ timestamp: number,
53
+ rawBody: string,
54
+ ): string {
55
+ if (typeof secret !== 'string' || secret.length === 0) {
56
+ throw new Error('[webhook-signing] secret must be a non-empty string');
57
+ }
58
+ if (!Number.isFinite(timestamp) || timestamp < 0) {
59
+ throw new Error('[webhook-signing] timestamp must be a non-negative number');
60
+ }
61
+ if (typeof rawBody !== 'string') {
62
+ throw new Error('[webhook-signing] rawBody must be a string');
63
+ }
64
+ const hmac = createHmac('sha256', secret);
65
+ hmac.update(`${timestamp}.${rawBody}`);
66
+ return hmac.digest('hex');
67
+ }
68
+
69
+ /**
70
+ * Build the full `X-Nest-Batch-Signature` header value for the
71
+ * given (timestamp, body) pair. The result is the literal
72
+ * header value, e.g. `t=1717941612,v1=4f3a...`.
73
+ */
74
+ export function buildSignatureHeader(
75
+ secret: string,
76
+ timestamp: number,
77
+ rawBody: string,
78
+ ): string {
79
+ const v1 = signV1(secret, timestamp, rawBody);
80
+ return `t=${timestamp},${SIGNATURE_VERSION}=${v1}`;
81
+ }
82
+
83
+ /**
84
+ * Parse a `X-Nest-Batch-Signature` header value back into its
85
+ * parts. Used by the test server to extract the `t=` and
86
+ * `v1=` fields for byte-equality verification.
87
+ *
88
+ * Throws on malformed input. Does NOT verify the HMAC; the
89
+ * caller is expected to call `verifyV1` with the original body.
90
+ */
91
+ export interface ParsedSignature {
92
+ readonly timestamp: number;
93
+ readonly v1: string;
94
+ }
95
+
96
+ export function parseSignatureHeader(header: string): ParsedSignature {
97
+ if (typeof header !== 'string' || header.length === 0) {
98
+ throw new Error('[webhook-signing] header is empty');
99
+ }
100
+ const parts = header.split(',').map((p) => p.trim());
101
+ let timestamp: number | undefined;
102
+ let v1: string | undefined;
103
+ for (const part of parts) {
104
+ if (part.startsWith('t=')) {
105
+ const raw = part.slice(2);
106
+ timestamp = Number.parseInt(raw, 10);
107
+ if (!Number.isFinite(timestamp) || timestamp < 0) {
108
+ throw new Error(`[webhook-signing] invalid t= value: ${raw}`);
109
+ }
110
+ } else if (part.startsWith(`${SIGNATURE_VERSION}=`)) {
111
+ v1 = part.slice(SIGNATURE_VERSION.length + 1);
112
+ if (v1.length === 0) {
113
+ throw new Error(`[webhook-signing] empty ${SIGNATURE_VERSION}= value`);
114
+ }
115
+ }
116
+ }
117
+ if (timestamp === undefined || v1 === undefined) {
118
+ throw new Error(
119
+ `[webhook-signing] header missing t= or ${SIGNATURE_VERSION}= field: ${header}`,
120
+ );
121
+ }
122
+ return { timestamp, v1 };
123
+ }
124
+
125
+ /**
126
+ * Timing-safe verification of a `X-Nest-Batch-Signature` header
127
+ * against a (secret, raw body) pair.
128
+ *
129
+ * Returns `true` iff the v1 HMAC matches. Uses `timingSafeEqual`
130
+ * to prevent timing-leak attacks on the comparison.
131
+ *
132
+ * Note: this helper is the SYMMETRIC counterpart of `signV1`.
133
+ * Receivers (the URL targets) call it after extracting the
134
+ * header value via `parseSignatureHeader`. The test suite uses
135
+ * it to assert byte-equality of the HMAC computed by the
136
+ * observer against the HMAC computed independently with the
137
+ * same secret + body.
138
+ */
139
+ export function verifyV1(
140
+ secret: string,
141
+ timestamp: number,
142
+ rawBody: string,
143
+ candidateV1: string,
144
+ ): boolean {
145
+ const expected = signV1(secret, timestamp, rawBody);
146
+ // Both `expected` and `candidateV1` are lowercase hex of the
147
+ // same length for a given (secret, body) pair, so the equal-
148
+ // length precondition of `timingSafeEqual` holds. Defensive
149
+ // length check: if the candidate is the wrong length, return
150
+ // false without invoking the constant-time compare (the
151
+ // length itself is not a secret).
152
+ if (candidateV1.length !== expected.length) return false;
153
+ try {
154
+ return timingSafeEqual(
155
+ Buffer.from(expected, 'hex'),
156
+ Buffer.from(candidateV1, 'hex'),
157
+ );
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Compute a SHA-256 fingerprint of the secret for use in
165
+ * dead-letter log lines. The host NEVER wants the secret (or a
166
+ * substring of it) in a log line, but operators often want a
167
+ * stable identifier to correlate dead-letter lines across
168
+ * services ("all 4xx dead-letters today used secret_sha256=abc...").
169
+ *
170
+ * Returns the first 12 hex chars of `sha256(secret)` — enough
171
+ * to be useful as a correlation tag, short enough that it
172
+ * cannot be brute-forced back to the secret.
173
+ */
174
+ export function fingerprintSecret(secret: string): string {
175
+ if (typeof secret !== 'string' || secret.length === 0) {
176
+ return '<missing>';
177
+ }
178
+ return createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 12);
179
+ }
180
+
181
+ /**
182
+ * The literal header name. Re-exported so the test server and
183
+ * the observer never have to repeat the magic string.
184
+ */
185
+ export const SIGNATURE_HEADER_NAME = SIGNATURE_HEADER;