webhook-delivery-engine 0.0.0-stage → 1.0.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 +111 -2
- package/dist/src/app/bootstrap.d.ts +14 -0
- package/dist/src/app/bootstrap.js +13 -0
- package/dist/src/app/embedded.d.ts +45 -0
- package/dist/src/app/embedded.js +228 -0
- package/dist/src/app/http.server.d.ts +10 -0
- package/dist/src/app/http.server.js +65 -0
- package/dist/src/app/lifecycle.d.ts +108 -0
- package/dist/src/app/lifecycle.js +138 -0
- package/dist/src/config/engine.config.d.ts +45 -0
- package/dist/src/config/engine.config.js +1 -0
- package/dist/src/core/errors.d.ts +4 -0
- package/dist/src/core/errors.js +7 -0
- package/dist/src/core/ports.d.ts +217 -0
- package/dist/src/core/ports.js +1 -0
- package/dist/src/core/types.d.ts +98 -0
- package/dist/src/core/types.js +10 -0
- package/dist/src/features/delivery/delivery.backoff.d.ts +74 -0
- package/dist/src/features/delivery/delivery.backoff.js +56 -0
- package/dist/src/features/delivery/delivery.classify.d.ts +28 -0
- package/dist/src/features/delivery/delivery.classify.js +38 -0
- package/dist/src/features/delivery/delivery.dispatcher.d.ts +57 -0
- package/dist/src/features/delivery/delivery.dispatcher.js +155 -0
- package/dist/src/features/delivery/delivery.hmac.d.ts +62 -0
- package/dist/src/features/delivery/delivery.hmac.js +31 -0
- package/dist/src/features/delivery/delivery.worker.d.ts +60 -0
- package/dist/src/features/delivery/delivery.worker.js +136 -0
- package/dist/src/features/http/http.responses.d.ts +6 -0
- package/dist/src/features/http/http.responses.js +45 -0
- package/dist/src/features/ingestion/ingestion.handler.d.ts +81 -0
- package/dist/src/features/ingestion/ingestion.handler.js +59 -0
- package/dist/src/features/ingestion/ingestion.routes.d.ts +12 -0
- package/dist/src/features/ingestion/ingestion.routes.js +25 -0
- package/dist/src/features/ingestion/ingestion.schema.d.ts +26 -0
- package/dist/src/features/ingestion/ingestion.schema.js +24 -0
- package/dist/src/features/query/query.routes.d.ts +11 -0
- package/dist/src/features/query/query.routes.js +99 -0
- package/dist/src/features/scheduler/scheduler.d.ts +110 -0
- package/dist/src/features/scheduler/scheduler.js +153 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +1 -0
- package/dist/src/security/auth.d.ts +40 -0
- package/dist/src/security/auth.js +39 -0
- package/dist/src/security/ssrf.d.ts +47 -0
- package/dist/src/security/ssrf.js +173 -0
- package/dist/src/shared/logger.d.ts +11 -0
- package/dist/src/shared/logger.js +52 -0
- package/dist/src/storage/migrations/runner.d.ts +51 -0
- package/dist/src/storage/migrations/runner.js +155 -0
- package/dist/src/storage/migrations/sql/sqlite/001_initial_schema.sql +52 -0
- package/dist/src/storage/sqlite.adapter.d.ts +51 -0
- package/dist/src/storage/sqlite.adapter.js +478 -0
- package/package.json +76 -4
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AttemptOutcome, EventStatus } from '../../core/types.js';
|
|
2
|
+
/** Inputs needed to classify one completed outbound dispatch. */
|
|
3
|
+
export interface ClassifyDeliveryResultInput {
|
|
4
|
+
/** HTTP response status, or null when dispatch did not receive a response. */
|
|
5
|
+
readonly statusCode: number | null;
|
|
6
|
+
/** 1-based attempt count reserved when the event was claimed. */
|
|
7
|
+
readonly attemptCount: number;
|
|
8
|
+
/** Configured maximum number of dispatch attempts. */
|
|
9
|
+
readonly maxAttempts: number;
|
|
10
|
+
}
|
|
11
|
+
/** Terminal event transition and corresponding durable attempt outcome. */
|
|
12
|
+
export interface DeliveryClassification {
|
|
13
|
+
readonly outcome: AttemptOutcome;
|
|
14
|
+
readonly nextStatus: Extract<EventStatus, 'delivered' | 'retry' | 'failed'>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Classifies the HTTP result and attempt ceiling into the next event state.
|
|
18
|
+
*
|
|
19
|
+
* Only 2xx responses succeed. 4xx responses retry only for 408, 409, and 429;
|
|
20
|
+
* other 4xx responses fail immediately. All other unsuccessful results retry
|
|
21
|
+
* until `maxAttempts`. The existing `exhausted` outcome marks either kind of
|
|
22
|
+
* terminal unsuccessful delivery.
|
|
23
|
+
*
|
|
24
|
+
* @param input - Dispatch status and configured retry limit.
|
|
25
|
+
* @returns The persisted outcome and next event state.
|
|
26
|
+
* @throws {RangeError} When the attempt count, limit, or status code is invalid.
|
|
27
|
+
*/
|
|
28
|
+
export declare function classifyDeliveryResult(input: ClassifyDeliveryResultInput): DeliveryClassification;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const RETRYABLE_CLIENT_ERRORS = new Set([408, 409, 429]);
|
|
2
|
+
/**
|
|
3
|
+
* Classifies the HTTP result and attempt ceiling into the next event state.
|
|
4
|
+
*
|
|
5
|
+
* Only 2xx responses succeed. 4xx responses retry only for 408, 409, and 429;
|
|
6
|
+
* other 4xx responses fail immediately. All other unsuccessful results retry
|
|
7
|
+
* until `maxAttempts`. The existing `exhausted` outcome marks either kind of
|
|
8
|
+
* terminal unsuccessful delivery.
|
|
9
|
+
*
|
|
10
|
+
* @param input - Dispatch status and configured retry limit.
|
|
11
|
+
* @returns The persisted outcome and next event state.
|
|
12
|
+
* @throws {RangeError} When the attempt count, limit, or status code is invalid.
|
|
13
|
+
*/
|
|
14
|
+
export function classifyDeliveryResult(input) {
|
|
15
|
+
if (!Number.isSafeInteger(input.attemptCount) || input.attemptCount < 1) {
|
|
16
|
+
throw new RangeError('attemptCount must be a positive safe integer.');
|
|
17
|
+
}
|
|
18
|
+
if (!Number.isSafeInteger(input.maxAttempts) || input.maxAttempts < 1) {
|
|
19
|
+
throw new RangeError('maxAttempts must be a positive safe integer.');
|
|
20
|
+
}
|
|
21
|
+
if (input.statusCode !== null &&
|
|
22
|
+
(!Number.isSafeInteger(input.statusCode) || input.statusCode < 100 || input.statusCode > 599)) {
|
|
23
|
+
throw new RangeError('statusCode must be null or an HTTP status from 100 through 599.');
|
|
24
|
+
}
|
|
25
|
+
if (input.statusCode !== null && input.statusCode >= 200 && input.statusCode < 300) {
|
|
26
|
+
return { outcome: 'success', nextStatus: 'delivered' };
|
|
27
|
+
}
|
|
28
|
+
if (input.statusCode !== null &&
|
|
29
|
+
input.statusCode >= 400 &&
|
|
30
|
+
input.statusCode < 500 &&
|
|
31
|
+
!RETRYABLE_CLIENT_ERRORS.has(input.statusCode)) {
|
|
32
|
+
return { outcome: 'exhausted', nextStatus: 'failed' };
|
|
33
|
+
}
|
|
34
|
+
if (input.attemptCount >= input.maxAttempts) {
|
|
35
|
+
return { outcome: 'exhausted', nextStatus: 'failed' };
|
|
36
|
+
}
|
|
37
|
+
return { outcome: 'retry_scheduled', nextStatus: 'retry' };
|
|
38
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { WebhookEvent } from '../../core/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Input required to dispatch one outbound webhook delivery attempt.
|
|
4
|
+
*/
|
|
5
|
+
export interface DispatchInput {
|
|
6
|
+
/** The claimed event to dispatch. */
|
|
7
|
+
readonly event: WebhookEvent;
|
|
8
|
+
/** HMAC secret used by the signing module (must be non-empty). */
|
|
9
|
+
readonly hmacSecret: string;
|
|
10
|
+
/** Maximum duration of the outbound HTTP request in milliseconds. */
|
|
11
|
+
readonly timeoutLimitMs: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Structured result of a single outbound HTTP dispatch attempt.
|
|
15
|
+
*
|
|
16
|
+
* Does NOT include the attempt outcome classification or DB writes — those
|
|
17
|
+
* are the responsibility of WBS 5.4.
|
|
18
|
+
*/
|
|
19
|
+
export interface DispatchResult {
|
|
20
|
+
/** HTTP response status code, or null when no response was received. */
|
|
21
|
+
readonly statusCode: number | null;
|
|
22
|
+
/** Round-trip duration in whole milliseconds. */
|
|
23
|
+
readonly latencyMs: number;
|
|
24
|
+
/**
|
|
25
|
+
* First 1 KB of the response body, or null when the body is empty or no
|
|
26
|
+
* response was received.
|
|
27
|
+
*/
|
|
28
|
+
readonly responseSnippet: string | null;
|
|
29
|
+
/**
|
|
30
|
+
* Non-secret error description for timeout or transport failures, or null
|
|
31
|
+
* on a successful HTTP exchange (any status code).
|
|
32
|
+
*/
|
|
33
|
+
readonly error: string | null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Executes one outbound HTTP POST to the event's destination URL.
|
|
37
|
+
*
|
|
38
|
+
* Responsibilities:
|
|
39
|
+
* - Serialise the event payload plus `webhookEventId` into a JSON body.
|
|
40
|
+
* - Sign the request with HMAC-SHA256 via {@link signRequest}.
|
|
41
|
+
* - Merge event-specific headers; engine-owned signing headers take
|
|
42
|
+
* precedence over any event-level header with the same name.
|
|
43
|
+
* - Re-resolve and classify the destination for this attempt, then pin one
|
|
44
|
+
* validated address in a request-scoped Undici Agent.
|
|
45
|
+
* - Bind an `AbortController` timer to `timeoutLimitMs`.
|
|
46
|
+
* - Capture status code, latency, and a ≤ 1 KB response snippet.
|
|
47
|
+
* - Map `AbortError` to `{ statusCode: null, error: 'timeout' }`.
|
|
48
|
+
* - Map other transport errors to `{ statusCode: null, error: '<generic>' }`
|
|
49
|
+
* without leaking internal details.
|
|
50
|
+
*
|
|
51
|
+
* Does NOT persist attempt records or apply outcome classification — those
|
|
52
|
+
* are handled by WBS 5.4.
|
|
53
|
+
*
|
|
54
|
+
* @param input - Event, HMAC secret, and timeout configuration.
|
|
55
|
+
* @returns A structured dispatch result.
|
|
56
|
+
*/
|
|
57
|
+
export declare function dispatchEvent(input: DispatchInput): Promise<DispatchResult>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
import { Agent, fetch, Headers as UndiciHeaders } from 'undici';
|
|
3
|
+
import { resolveDestinationForDispatch } from '../../security/ssrf.js';
|
|
4
|
+
import { signRequest } from './delivery.hmac.js';
|
|
5
|
+
/** Maximum response body length captured as a snippet (SDD §4.2). */
|
|
6
|
+
const MAX_SNIPPET_BYTES = 1024;
|
|
7
|
+
function pinnedLookup(expectedHostname, addresses) {
|
|
8
|
+
const pinnedAddress = addresses[0];
|
|
9
|
+
const pinnedFamily = pinnedAddress === undefined ? 0 : isIP(pinnedAddress);
|
|
10
|
+
return (hostname, options, callback) => {
|
|
11
|
+
const requestedHostname = hostname.toLowerCase().replace(/\.$/u, '');
|
|
12
|
+
const pinnedHostname = expectedHostname.toLowerCase().replace(/\.$/u, '');
|
|
13
|
+
if (requestedHostname !== pinnedHostname) {
|
|
14
|
+
const error = Object.assign(new Error('Pinned destination mismatch'), { code: 'EAI_FAIL' });
|
|
15
|
+
if (options.all) {
|
|
16
|
+
callback(error, []);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
callback(error, '', 0);
|
|
20
|
+
}
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (pinnedAddress === undefined || pinnedFamily === 0) {
|
|
24
|
+
const error = Object.assign(new Error('No validated destination address'), {
|
|
25
|
+
code: 'EAI_NONAME',
|
|
26
|
+
});
|
|
27
|
+
if (options.all) {
|
|
28
|
+
callback(error, []);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
callback(error, '', 0);
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (options.all) {
|
|
36
|
+
callback(null, [{ address: pinnedAddress, family: pinnedFamily }]);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
callback(null, pinnedAddress, pinnedFamily);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Executes one outbound HTTP POST to the event's destination URL.
|
|
45
|
+
*
|
|
46
|
+
* Responsibilities:
|
|
47
|
+
* - Serialise the event payload plus `webhookEventId` into a JSON body.
|
|
48
|
+
* - Sign the request with HMAC-SHA256 via {@link signRequest}.
|
|
49
|
+
* - Merge event-specific headers; engine-owned signing headers take
|
|
50
|
+
* precedence over any event-level header with the same name.
|
|
51
|
+
* - Re-resolve and classify the destination for this attempt, then pin one
|
|
52
|
+
* validated address in a request-scoped Undici Agent.
|
|
53
|
+
* - Bind an `AbortController` timer to `timeoutLimitMs`.
|
|
54
|
+
* - Capture status code, latency, and a ≤ 1 KB response snippet.
|
|
55
|
+
* - Map `AbortError` to `{ statusCode: null, error: 'timeout' }`.
|
|
56
|
+
* - Map other transport errors to `{ statusCode: null, error: '<generic>' }`
|
|
57
|
+
* without leaking internal details.
|
|
58
|
+
*
|
|
59
|
+
* Does NOT persist attempt records or apply outcome classification — those
|
|
60
|
+
* are handled by WBS 5.4.
|
|
61
|
+
*
|
|
62
|
+
* @param input - Event, HMAC secret, and timeout configuration.
|
|
63
|
+
* @returns A structured dispatch result.
|
|
64
|
+
*/
|
|
65
|
+
export async function dispatchEvent(input) {
|
|
66
|
+
const { event, hmacSecret, timeoutLimitMs } = input;
|
|
67
|
+
// Build the outbound JSON body: payload merged with the engine-injected id.
|
|
68
|
+
const bodyObject = {
|
|
69
|
+
...(typeof event.payload === 'object' && event.payload !== null && !Array.isArray(event.payload)
|
|
70
|
+
? event.payload
|
|
71
|
+
: { payload: event.payload }),
|
|
72
|
+
webhookEventId: event.id,
|
|
73
|
+
};
|
|
74
|
+
const bodyString = JSON.stringify(bodyObject);
|
|
75
|
+
// Compute HMAC-signed engine headers.
|
|
76
|
+
const signedHeaders = signRequest({
|
|
77
|
+
secret: hmacSecret,
|
|
78
|
+
eventId: event.id,
|
|
79
|
+
body: bodyString,
|
|
80
|
+
});
|
|
81
|
+
// Merge headers: event-specific first, engine-owned last (engine wins).
|
|
82
|
+
const mergedHeaders = new UndiciHeaders({
|
|
83
|
+
'content-type': 'application/json',
|
|
84
|
+
...event.headers,
|
|
85
|
+
...signedHeaders,
|
|
86
|
+
});
|
|
87
|
+
// Bind an AbortController to enforce the request timeout.
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
const timer = setTimeout(() => controller.abort(), timeoutLimitMs);
|
|
90
|
+
let rejectOnAbort;
|
|
91
|
+
const abortPromise = new Promise((_resolve, reject) => {
|
|
92
|
+
rejectOnAbort = reject;
|
|
93
|
+
});
|
|
94
|
+
const onAbort = () => rejectOnAbort?.(new DOMException('The operation was aborted', 'AbortError'));
|
|
95
|
+
controller.signal.addEventListener('abort', onAbort, { once: true });
|
|
96
|
+
const startMs = Date.now();
|
|
97
|
+
let agent;
|
|
98
|
+
try {
|
|
99
|
+
const resolution = await Promise.race([
|
|
100
|
+
resolveDestinationForDispatch(event.destinationUrl),
|
|
101
|
+
abortPromise,
|
|
102
|
+
]);
|
|
103
|
+
if (!resolution.valid) {
|
|
104
|
+
return {
|
|
105
|
+
statusCode: null,
|
|
106
|
+
latencyMs: Date.now() - startMs,
|
|
107
|
+
responseSnippet: null,
|
|
108
|
+
error: 'network error',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
agent = new Agent({
|
|
112
|
+
connections: 1,
|
|
113
|
+
pipelining: 0,
|
|
114
|
+
maxCachedSessions: 0,
|
|
115
|
+
connect: {
|
|
116
|
+
lookup: pinnedLookup(resolution.hostname, resolution.addresses),
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
const response = await fetch(resolution.url, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: mergedHeaders,
|
|
122
|
+
body: bodyString,
|
|
123
|
+
signal: controller.signal,
|
|
124
|
+
dispatcher: agent,
|
|
125
|
+
redirect: 'error', // never follow redirects (SDD §5.1 / ADR-0006)
|
|
126
|
+
});
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
const latencyMs = Date.now() - startMs;
|
|
129
|
+
const rawBody = await response.text();
|
|
130
|
+
const responseSnippet = rawBody.length === 0 ? null : rawBody.slice(0, MAX_SNIPPET_BYTES);
|
|
131
|
+
return {
|
|
132
|
+
statusCode: response.status,
|
|
133
|
+
latencyMs,
|
|
134
|
+
responseSnippet,
|
|
135
|
+
error: null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
const latencyMs = Date.now() - startMs;
|
|
140
|
+
const isAbort = controller.signal.aborted || (error instanceof DOMException && error.name === 'AbortError');
|
|
141
|
+
return {
|
|
142
|
+
statusCode: null,
|
|
143
|
+
latencyMs,
|
|
144
|
+
responseSnippet: null,
|
|
145
|
+
error: isAbort ? 'timeout' : 'network error',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
controller.signal.removeEventListener('abort', onAbort);
|
|
151
|
+
if (agent !== undefined) {
|
|
152
|
+
await agent.close();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input required to produce a signed set of outbound delivery headers.
|
|
3
|
+
*/
|
|
4
|
+
export interface SignRequestInput {
|
|
5
|
+
/**
|
|
6
|
+
* HMAC secret used to compute the signature.
|
|
7
|
+
*
|
|
8
|
+
* Must be non-empty; an empty secret is rejected to enforce FR-13 (no
|
|
9
|
+
* unsigned dispatch).
|
|
10
|
+
*/
|
|
11
|
+
readonly secret: string;
|
|
12
|
+
/**
|
|
13
|
+
* System-generated event id written into `x-webhook-event-id`.
|
|
14
|
+
*
|
|
15
|
+
* Enables the consumer to deduplicate at-least-once deliveries by a stable,
|
|
16
|
+
* non-enumerable identifier (A2, C4).
|
|
17
|
+
*/
|
|
18
|
+
readonly eventId: string;
|
|
19
|
+
/**
|
|
20
|
+
* Raw JSON body string that will be sent in the request body.
|
|
21
|
+
*
|
|
22
|
+
* The signature is computed over `"${timestampSeconds}.${body}"` so the
|
|
23
|
+
* consumer can independently verify integrity and freshness.
|
|
24
|
+
*/
|
|
25
|
+
readonly body: string;
|
|
26
|
+
/**
|
|
27
|
+
* Optional epoch millisecond clock override for testing.
|
|
28
|
+
*
|
|
29
|
+
* When omitted, `Date.now()` is used. Injecting a fixed value produces
|
|
30
|
+
* deterministic timestamps and signatures in tests without side effects.
|
|
31
|
+
*/
|
|
32
|
+
readonly nowMs?: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The three engine-owned outbound headers produced by {@link signRequest}.
|
|
36
|
+
*
|
|
37
|
+
* All values are strings so the record is directly spreadable into any
|
|
38
|
+
* headers map accepted by the HTTP client.
|
|
39
|
+
*/
|
|
40
|
+
export interface SignedHeaders {
|
|
41
|
+
/** HMAC-SHA256 signature prefixed with `sha256=` (FR-11). */
|
|
42
|
+
readonly 'x-webhook-signature': string;
|
|
43
|
+
/** Request timestamp in Unix seconds as a decimal string (FR-12). */
|
|
44
|
+
readonly 'x-webhook-timestamp': string;
|
|
45
|
+
/** System event id for consumer deduplication (A2). */
|
|
46
|
+
readonly 'x-webhook-event-id': string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Computes and returns the three engine-owned outbound delivery headers.
|
|
50
|
+
*
|
|
51
|
+
* The signature is `HMAC-SHA256(secret, timestampSeconds + '.' + body)` where
|
|
52
|
+
* `timestampSeconds` is the floor of `nowMs / 1000`. Including the timestamp
|
|
53
|
+
* in the signed material defeats replay attacks on captured requests (NFR-S2).
|
|
54
|
+
*
|
|
55
|
+
* @param input - Signing material: secret, event id, raw JSON body, and an
|
|
56
|
+
* optional fixed-clock override for deterministic testing.
|
|
57
|
+
* @returns The three canonical signed headers ready to merge into the outbound
|
|
58
|
+
* request headers map.
|
|
59
|
+
* @throws {Error} When `secret` is an empty string (FR-13: no unsigned
|
|
60
|
+
* dispatch).
|
|
61
|
+
*/
|
|
62
|
+
export declare function signRequest(input: SignRequestInput): SignedHeaders;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Computes and returns the three engine-owned outbound delivery headers.
|
|
4
|
+
*
|
|
5
|
+
* The signature is `HMAC-SHA256(secret, timestampSeconds + '.' + body)` where
|
|
6
|
+
* `timestampSeconds` is the floor of `nowMs / 1000`. Including the timestamp
|
|
7
|
+
* in the signed material defeats replay attacks on captured requests (NFR-S2).
|
|
8
|
+
*
|
|
9
|
+
* @param input - Signing material: secret, event id, raw JSON body, and an
|
|
10
|
+
* optional fixed-clock override for deterministic testing.
|
|
11
|
+
* @returns The three canonical signed headers ready to merge into the outbound
|
|
12
|
+
* request headers map.
|
|
13
|
+
* @throws {Error} When `secret` is an empty string (FR-13: no unsigned
|
|
14
|
+
* dispatch).
|
|
15
|
+
*/
|
|
16
|
+
export function signRequest(input) {
|
|
17
|
+
if (input.secret.length === 0) {
|
|
18
|
+
throw new Error('HMAC secret must not be empty; unsigned dispatch is not permitted (FR-13).');
|
|
19
|
+
}
|
|
20
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
21
|
+
const timestampSeconds = String(Math.floor(nowMs / 1000));
|
|
22
|
+
const signedPayload = `${timestampSeconds}.${input.body}`;
|
|
23
|
+
const signature = createHmac('sha256', input.secret)
|
|
24
|
+
.update(signedPayload)
|
|
25
|
+
.digest('hex');
|
|
26
|
+
return {
|
|
27
|
+
'x-webhook-signature': `sha256=${signature}`,
|
|
28
|
+
'x-webhook-timestamp': timestampSeconds,
|
|
29
|
+
'x-webhook-event-id': input.eventId,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { RetryPolicy } from '../../config/engine.config.js';
|
|
2
|
+
import type { DatabaseAdapter } from '../../core/ports.js';
|
|
3
|
+
import type { AttemptRecord, EventStatus, Timestamp, WebhookEvent } from '../../core/types.js';
|
|
4
|
+
import { dispatchEvent, type DispatchInput } from './delivery.dispatcher.js';
|
|
5
|
+
import type { OnClaimCallback } from '../scheduler/scheduler.js';
|
|
6
|
+
/** Narrow persistence boundary required by one delivery attempt. */
|
|
7
|
+
export type DeliveryAttemptAdapter = Pick<DatabaseAdapter, 'recordAttemptAndTransition'>;
|
|
8
|
+
/** Dependencies for processing one claimed event. */
|
|
9
|
+
export interface DeliveryProcessorOptions {
|
|
10
|
+
readonly adapter: DeliveryAttemptAdapter;
|
|
11
|
+
readonly hmacSecret: string;
|
|
12
|
+
readonly timeoutLimitMs: number;
|
|
13
|
+
readonly retry: RetryPolicy;
|
|
14
|
+
/** Optional transport seam, primarily useful for deterministic tests. */
|
|
15
|
+
readonly dispatch?: (input: DispatchInput) => ReturnType<typeof dispatchEvent>;
|
|
16
|
+
/** UTC time source, called once before dispatch and once before persistence. */
|
|
17
|
+
readonly now?: () => Timestamp;
|
|
18
|
+
/** Identifier source for the append-only attempt row. */
|
|
19
|
+
readonly createId?: () => string;
|
|
20
|
+
/** Called after persistence returns the attempt transition result. */
|
|
21
|
+
readonly onAttemptCompleted?: (event: WebhookEvent, result: DeliveryAttemptCompletion) => void;
|
|
22
|
+
}
|
|
23
|
+
/** Safe, payload-free summary of an attempt and its persistence result. */
|
|
24
|
+
export interface DeliveryAttemptCompletion {
|
|
25
|
+
readonly outcome: AttemptRecord['outcome'];
|
|
26
|
+
readonly nextStatus: Extract<EventStatus, 'delivered' | 'retry' | 'failed'>;
|
|
27
|
+
readonly statusCode: number | null;
|
|
28
|
+
readonly latencyMs: number;
|
|
29
|
+
readonly persisted: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** Processing failure callback for the scheduler-compatible worker adapter. */
|
|
32
|
+
export type DeliveryWorkerErrorHandler = (error: unknown, event: WebhookEvent) => void;
|
|
33
|
+
/** Dependencies for adapting the async processor to the scheduler callback. */
|
|
34
|
+
export interface DeliveryWorkerOptions extends DeliveryProcessorOptions {
|
|
35
|
+
readonly onError: DeliveryWorkerErrorHandler;
|
|
36
|
+
/** Called after dispatch starts with the promise that includes persistence. */
|
|
37
|
+
readonly onDispatchStarted?: (event: WebhookEvent, work: Promise<boolean>) => void;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Dispatches one claimed event, classifies its result, and atomically records
|
|
41
|
+
* the attempt with the resulting event transition.
|
|
42
|
+
*
|
|
43
|
+
* `WebhookEvent.attemptCount` is already reserved by the claim operation, so
|
|
44
|
+
* it is also this attempt's 1-based audit ordinal. A lost lease is returned as
|
|
45
|
+
* `false` by the adapter without writing a stale attempt or state transition.
|
|
46
|
+
*
|
|
47
|
+
* @param event - Processing event returned by the atomic claim operation.
|
|
48
|
+
* @param options - Adapter, signing, timeout, retry, and optional test seams.
|
|
49
|
+
* @returns Whether the adapter committed the attempt and state transition.
|
|
50
|
+
* @throws When the event has no active lease or a dependency fails.
|
|
51
|
+
*/
|
|
52
|
+
export declare function processDeliveryAttempt(event: WebhookEvent, options: DeliveryProcessorOptions): Promise<boolean>;
|
|
53
|
+
/**
|
|
54
|
+
* Creates a synchronous callback accepted by `ClaimScheduler` while keeping
|
|
55
|
+
* delivery and database failures observable through the required error hook.
|
|
56
|
+
*
|
|
57
|
+
* @param options - Processor dependencies and mandatory error handler.
|
|
58
|
+
* @returns A scheduler-compatible callback that starts one async attempt.
|
|
59
|
+
*/
|
|
60
|
+
export declare function createDeliveryWorker(options: DeliveryWorkerOptions): OnClaimCallback;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { Buffer } from 'node:buffer';
|
|
3
|
+
import { calculateNextRetryAt } from './delivery.backoff.js';
|
|
4
|
+
import { classifyDeliveryResult } from './delivery.classify.js';
|
|
5
|
+
import { dispatchEvent } from './delivery.dispatcher.js';
|
|
6
|
+
/**
|
|
7
|
+
* Dispatches one claimed event, classifies its result, and atomically records
|
|
8
|
+
* the attempt with the resulting event transition.
|
|
9
|
+
*
|
|
10
|
+
* `WebhookEvent.attemptCount` is already reserved by the claim operation, so
|
|
11
|
+
* it is also this attempt's 1-based audit ordinal. A lost lease is returned as
|
|
12
|
+
* `false` by the adapter without writing a stale attempt or state transition.
|
|
13
|
+
*
|
|
14
|
+
* @param event - Processing event returned by the atomic claim operation.
|
|
15
|
+
* @param options - Adapter, signing, timeout, retry, and optional test seams.
|
|
16
|
+
* @returns Whether the adapter committed the attempt and state transition.
|
|
17
|
+
* @throws When the event has no active lease or a dependency fails.
|
|
18
|
+
*/
|
|
19
|
+
export async function processDeliveryAttempt(event, options) {
|
|
20
|
+
if (event.status !== 'processing' || event.lockedBy === null) {
|
|
21
|
+
throw new Error('The event must have an active processing lease before dispatch.');
|
|
22
|
+
}
|
|
23
|
+
if (!Number.isSafeInteger(event.attemptCount) || event.attemptCount < 1) {
|
|
24
|
+
throw new RangeError('event.attemptCount must be a positive safe integer.');
|
|
25
|
+
}
|
|
26
|
+
const attemptedAt = (options.now ?? defaultNow)();
|
|
27
|
+
const dispatch = options.dispatch ?? dispatchEvent;
|
|
28
|
+
const result = await dispatch({
|
|
29
|
+
event,
|
|
30
|
+
hmacSecret: options.hmacSecret,
|
|
31
|
+
timeoutLimitMs: options.timeoutLimitMs,
|
|
32
|
+
});
|
|
33
|
+
const updatedAt = (options.now ?? defaultNow)();
|
|
34
|
+
const classification = classifyDeliveryResult({
|
|
35
|
+
statusCode: result.statusCode,
|
|
36
|
+
attemptCount: event.attemptCount,
|
|
37
|
+
maxAttempts: options.retry.maxAttempts,
|
|
38
|
+
});
|
|
39
|
+
const nextRetryAt = classification.outcome === 'retry_scheduled'
|
|
40
|
+
? calculateNextRetryAt({
|
|
41
|
+
attempt: event.attemptCount,
|
|
42
|
+
baseDelayMs: options.retry.baseDelayMs,
|
|
43
|
+
maxDelayMs: options.retry.maxDelayMs,
|
|
44
|
+
now: updatedAt,
|
|
45
|
+
}).nextRetryAt
|
|
46
|
+
: null;
|
|
47
|
+
const attempt = {
|
|
48
|
+
id: (options.createId ?? randomUUID)(),
|
|
49
|
+
eventId: event.id,
|
|
50
|
+
attemptNumber: event.attemptCount,
|
|
51
|
+
attemptedAt,
|
|
52
|
+
statusCode: result.statusCode,
|
|
53
|
+
responseSnippet: truncateSnippet(result.responseSnippet),
|
|
54
|
+
error: result.error ?? fallbackError(result.statusCode, classification.outcome),
|
|
55
|
+
latencyMs: result.latencyMs,
|
|
56
|
+
outcome: classification.outcome,
|
|
57
|
+
};
|
|
58
|
+
const transition = createTransition(attempt, event.lockedBy, classification.outcome, nextRetryAt, updatedAt);
|
|
59
|
+
const persisted = await options.adapter.recordAttemptAndTransition(transition);
|
|
60
|
+
options.onAttemptCompleted?.(event, {
|
|
61
|
+
outcome: classification.outcome,
|
|
62
|
+
nextStatus: transition.nextStatus,
|
|
63
|
+
statusCode: result.statusCode,
|
|
64
|
+
latencyMs: result.latencyMs,
|
|
65
|
+
persisted,
|
|
66
|
+
});
|
|
67
|
+
return persisted;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Creates a synchronous callback accepted by `ClaimScheduler` while keeping
|
|
71
|
+
* delivery and database failures observable through the required error hook.
|
|
72
|
+
*
|
|
73
|
+
* @param options - Processor dependencies and mandatory error handler.
|
|
74
|
+
* @returns A scheduler-compatible callback that starts one async attempt.
|
|
75
|
+
*/
|
|
76
|
+
export function createDeliveryWorker(options) {
|
|
77
|
+
return (event) => {
|
|
78
|
+
const work = processDeliveryAttempt(event, options);
|
|
79
|
+
options.onDispatchStarted?.(event, work);
|
|
80
|
+
void work.catch((error) => {
|
|
81
|
+
options.onError(error, event);
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function createTransition(attempt, workerId, outcome, nextRetryAt, updatedAt) {
|
|
86
|
+
switch (outcome) {
|
|
87
|
+
case 'success':
|
|
88
|
+
return {
|
|
89
|
+
attempt: { ...attempt, outcome },
|
|
90
|
+
workerId,
|
|
91
|
+
nextStatus: 'delivered',
|
|
92
|
+
nextRetryAt: null,
|
|
93
|
+
updatedAt,
|
|
94
|
+
};
|
|
95
|
+
case 'retry_scheduled':
|
|
96
|
+
// This path is reached only after calculating the retry timestamp for
|
|
97
|
+
// the discriminated retry_scheduled outcome above.
|
|
98
|
+
return {
|
|
99
|
+
attempt: { ...attempt, outcome },
|
|
100
|
+
workerId,
|
|
101
|
+
nextStatus: 'retry',
|
|
102
|
+
nextRetryAt: nextRetryAt,
|
|
103
|
+
updatedAt,
|
|
104
|
+
};
|
|
105
|
+
case 'exhausted':
|
|
106
|
+
return {
|
|
107
|
+
attempt: { ...attempt, outcome },
|
|
108
|
+
workerId,
|
|
109
|
+
nextStatus: 'failed',
|
|
110
|
+
nextRetryAt: null,
|
|
111
|
+
updatedAt,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function fallbackError(statusCode, outcome) {
|
|
116
|
+
if (outcome === 'success')
|
|
117
|
+
return null;
|
|
118
|
+
return statusCode === null ? 'network error' : `HTTP ${statusCode}`;
|
|
119
|
+
}
|
|
120
|
+
function defaultNow() {
|
|
121
|
+
return new Date().toISOString();
|
|
122
|
+
}
|
|
123
|
+
function truncateSnippet(snippet) {
|
|
124
|
+
if (snippet === null || Buffer.byteLength(snippet, 'utf8') <= 1024)
|
|
125
|
+
return snippet;
|
|
126
|
+
let byteLength = 0;
|
|
127
|
+
let truncated = '';
|
|
128
|
+
for (const character of snippet) {
|
|
129
|
+
const characterBytes = Buffer.byteLength(character, 'utf8');
|
|
130
|
+
if (byteLength + characterBytes > 1024)
|
|
131
|
+
break;
|
|
132
|
+
truncated += character;
|
|
133
|
+
byteLength += characterBytes;
|
|
134
|
+
}
|
|
135
|
+
return truncated;
|
|
136
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { AttemptRecord, WebhookEvent } from '../../core/types.js';
|
|
3
|
+
export declare function sendJson(response: ServerResponse, statusCode: number, body: unknown): void;
|
|
4
|
+
export declare function readJsonBody(request: IncomingMessage): Promise<unknown>;
|
|
5
|
+
export declare function serializeEvent(event: WebhookEvent): Record<string, unknown>;
|
|
6
|
+
export declare function serializeAttempt(attempt: AttemptRecord): Record<string, unknown>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function sendJson(response, statusCode, body) {
|
|
2
|
+
response.writeHead(statusCode, {
|
|
3
|
+
'content-type': 'application/json; charset=utf-8',
|
|
4
|
+
'x-content-type-options': 'nosniff',
|
|
5
|
+
});
|
|
6
|
+
response.end(JSON.stringify(body));
|
|
7
|
+
}
|
|
8
|
+
export async function readJsonBody(request) {
|
|
9
|
+
const chunks = [];
|
|
10
|
+
for await (const chunk of request) {
|
|
11
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
12
|
+
}
|
|
13
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
14
|
+
}
|
|
15
|
+
export function serializeEvent(event) {
|
|
16
|
+
return {
|
|
17
|
+
id: event.id,
|
|
18
|
+
idempotency_key: event.idempotencyKey,
|
|
19
|
+
client_id: event.clientId,
|
|
20
|
+
destination_url: event.destinationUrl,
|
|
21
|
+
payload: event.payload,
|
|
22
|
+
metadata: event.metadata,
|
|
23
|
+
headers: event.headers,
|
|
24
|
+
status: event.status,
|
|
25
|
+
attempt_count: event.attemptCount,
|
|
26
|
+
next_retry_at: event.nextRetryAt,
|
|
27
|
+
locked_at: event.lockedAt,
|
|
28
|
+
locked_by: event.lockedBy,
|
|
29
|
+
created_at: event.createdAt,
|
|
30
|
+
updated_at: event.updatedAt,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function serializeAttempt(attempt) {
|
|
34
|
+
return {
|
|
35
|
+
id: attempt.id,
|
|
36
|
+
event_id: attempt.eventId,
|
|
37
|
+
attempt_number: attempt.attemptNumber,
|
|
38
|
+
attempted_at: attempt.attemptedAt,
|
|
39
|
+
status_code: attempt.statusCode,
|
|
40
|
+
response_snippet: attempt.responseSnippet,
|
|
41
|
+
error: attempt.error,
|
|
42
|
+
latency_ms: attempt.latencyMs,
|
|
43
|
+
outcome: attempt.outcome,
|
|
44
|
+
};
|
|
45
|
+
}
|