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,39 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
const unauthorizedResult = Object.freeze({
|
|
3
|
+
authenticated: false,
|
|
4
|
+
statusCode: 401,
|
|
5
|
+
});
|
|
6
|
+
function digest(value) {
|
|
7
|
+
return createHash('sha256').update(value, 'utf8').digest();
|
|
8
|
+
}
|
|
9
|
+
function createAuthenticator(expectedDigest, clientId) {
|
|
10
|
+
return (candidateToken) => {
|
|
11
|
+
const token = typeof candidateToken === 'string' ? candidateToken : '';
|
|
12
|
+
const candidateDigest = digest(token);
|
|
13
|
+
const authenticated = timingSafeEqual(expectedDigest, candidateDigest);
|
|
14
|
+
return authenticated ? { authenticated: true, clientId } : unauthorizedResult;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Creates an authenticator for one configured API key and tenant identity.
|
|
19
|
+
*
|
|
20
|
+
* The configured key is reduced to a fixed-length SHA-256 digest before the
|
|
21
|
+
* returned function is exposed. Each candidate, including an absent token,
|
|
22
|
+
* is hashed and compared with `timingSafeEqual`; credentials are never
|
|
23
|
+
* returned or logged. The HTTP layer maps an unauthorized result to a 401
|
|
24
|
+
* response.
|
|
25
|
+
*
|
|
26
|
+
* @param config - API key and its configured tenant identity.
|
|
27
|
+
* @returns A function that returns the tenant identity only for a valid key.
|
|
28
|
+
* @throws {@link TypeError} when either configured value is not a non-empty string.
|
|
29
|
+
*/
|
|
30
|
+
export function createApiKeyAuthenticator(config) {
|
|
31
|
+
const { apiKey, clientId } = config;
|
|
32
|
+
if (typeof apiKey !== 'string' ||
|
|
33
|
+
apiKey.length === 0 ||
|
|
34
|
+
typeof clientId !== 'string' ||
|
|
35
|
+
clientId.length === 0) {
|
|
36
|
+
throw new TypeError('apiKey and clientId must be non-empty strings.');
|
|
37
|
+
}
|
|
38
|
+
return createAuthenticator(digest(apiKey), clientId);
|
|
39
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A destination URL validation result that never exposes DNS answers or
|
|
3
|
+
* resolver error details.
|
|
4
|
+
*/
|
|
5
|
+
export type DestinationUrlValidationResult = {
|
|
6
|
+
readonly valid: true;
|
|
7
|
+
readonly url: string;
|
|
8
|
+
} | {
|
|
9
|
+
readonly valid: false;
|
|
10
|
+
readonly reason: 'invalid_url' | 'unsupported_protocol' | 'credentials_not_allowed' | 'unsafe_address' | 'dns_resolution_failed';
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Internal delivery-only DNS result. Callers must not expose addresses in
|
|
14
|
+
* public API results, logs, or persisted records.
|
|
15
|
+
*/
|
|
16
|
+
export type DestinationDispatchResolutionResult = {
|
|
17
|
+
readonly valid: true;
|
|
18
|
+
readonly url: string;
|
|
19
|
+
readonly hostname: string;
|
|
20
|
+
readonly addresses: readonly string[];
|
|
21
|
+
} | {
|
|
22
|
+
readonly valid: false;
|
|
23
|
+
readonly reason: Extract<DestinationUrlValidationResult, {
|
|
24
|
+
valid: false;
|
|
25
|
+
}>['reason'];
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Resolves and validates a destination immediately before HTTP dispatch.
|
|
29
|
+
*
|
|
30
|
+
* Only absolute HTTPS URLs without embedded credentials are accepted. Literal
|
|
31
|
+
* addresses are classified without DNS; hostnames are rejected if any
|
|
32
|
+
* resolved IPv4 or IPv6 address is unsafe. Hostname lookup requests every
|
|
33
|
+
* address using Node DNS. Successful results include the vetted addresses for
|
|
34
|
+
* the delivery connector to pin. This internal result must never be exposed
|
|
35
|
+
* through the public API or logs.
|
|
36
|
+
*
|
|
37
|
+
* @param destinationUrl - Persisted destination URL for one dispatch attempt.
|
|
38
|
+
* @returns A canonical URL and vetted addresses or a generic safe failure.
|
|
39
|
+
*/
|
|
40
|
+
export declare function resolveDestinationForDispatch(destinationUrl: string): Promise<DestinationDispatchResolutionResult>;
|
|
41
|
+
/**
|
|
42
|
+
* Validates a destination URL before it can be persisted.
|
|
43
|
+
*
|
|
44
|
+
* @param destinationUrl - Untrusted destination URL supplied by a caller or configuration.
|
|
45
|
+
* @returns A canonical URL on success or a generic safe failure reason.
|
|
46
|
+
*/
|
|
47
|
+
export declare function validateDestinationUrl(destinationUrl: string): Promise<DestinationUrlValidationResult>;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
const blockedIpv4Cidrs = [
|
|
4
|
+
['0.0.0.0', 8],
|
|
5
|
+
['10.0.0.0', 8],
|
|
6
|
+
['100.64.0.0', 10],
|
|
7
|
+
['127.0.0.0', 8],
|
|
8
|
+
['169.254.0.0', 16],
|
|
9
|
+
['172.16.0.0', 12],
|
|
10
|
+
['192.0.0.0', 24],
|
|
11
|
+
['192.0.2.0', 24],
|
|
12
|
+
['192.31.196.0', 24],
|
|
13
|
+
['192.52.193.0', 24],
|
|
14
|
+
['192.88.99.0', 24],
|
|
15
|
+
['192.168.0.0', 16],
|
|
16
|
+
['192.175.48.0', 24],
|
|
17
|
+
['198.18.0.0', 15],
|
|
18
|
+
['198.51.100.0', 24],
|
|
19
|
+
['203.0.113.0', 24],
|
|
20
|
+
['224.0.0.0', 4],
|
|
21
|
+
['240.0.0.0', 4],
|
|
22
|
+
];
|
|
23
|
+
const blockedIpv6Cidrs = [
|
|
24
|
+
['2001::', 23],
|
|
25
|
+
['2001:db8::', 32],
|
|
26
|
+
['2002::', 16],
|
|
27
|
+
['3fff::', 20],
|
|
28
|
+
];
|
|
29
|
+
function ipv4ToBigInt(address) {
|
|
30
|
+
return address.split('.').reduce((value, octet) => (value << 8n) | BigInt(octet), 0n);
|
|
31
|
+
}
|
|
32
|
+
function ipv6ToBigInt(address) {
|
|
33
|
+
const halves = address.toLowerCase().split('::');
|
|
34
|
+
const left = halves[0] === '' ? [] : halves[0].split(':');
|
|
35
|
+
const right = halves.length === 2 && halves[1] !== '' ? halves[1].split(':') : [];
|
|
36
|
+
const zeroGroups = 8 - left.length - right.length;
|
|
37
|
+
const groups = [...left, ...Array.from({ length: zeroGroups }, () => '0'), ...right];
|
|
38
|
+
return groups.reduce((value, group) => (value << 16n) | BigInt(`0x${group}`), 0n);
|
|
39
|
+
}
|
|
40
|
+
function isIpv4InCidr(address, network, prefixLength) {
|
|
41
|
+
const shift = BigInt(32 - prefixLength);
|
|
42
|
+
return ipv4ToBigInt(address) >> shift === ipv4ToBigInt(network) >> shift;
|
|
43
|
+
}
|
|
44
|
+
function isIpv6InCidr(address, network, prefixLength) {
|
|
45
|
+
const networkAddress = ipv6ToBigInt(network);
|
|
46
|
+
const shift = BigInt(128 - prefixLength);
|
|
47
|
+
return address >> shift === networkAddress >> shift;
|
|
48
|
+
}
|
|
49
|
+
function isBlockedIpv4(address) {
|
|
50
|
+
return blockedIpv4Cidrs.some(([network, prefixLength]) => isIpv4InCidr(address, network, prefixLength));
|
|
51
|
+
}
|
|
52
|
+
function isBlockedIpv6(address) {
|
|
53
|
+
const addressValue = ipv6ToBigInt(address);
|
|
54
|
+
const mappedIpv4Prefix = addressValue >> 32n;
|
|
55
|
+
if (mappedIpv4Prefix === 0xffffn) {
|
|
56
|
+
const embeddedIpv4 = Number(addressValue & 0xffffffffn);
|
|
57
|
+
const ipv4Address = [
|
|
58
|
+
(embeddedIpv4 >>> 24) & 0xff,
|
|
59
|
+
(embeddedIpv4 >>> 16) & 0xff,
|
|
60
|
+
(embeddedIpv4 >>> 8) & 0xff,
|
|
61
|
+
embeddedIpv4 & 0xff,
|
|
62
|
+
].join('.');
|
|
63
|
+
return isBlockedIpv4(ipv4Address);
|
|
64
|
+
}
|
|
65
|
+
// Only global-unicast IPv6 is eligible; this excludes unspecified, loopback,
|
|
66
|
+
// ULA, link-local, multicast, and other non-global allocation ranges.
|
|
67
|
+
if (!isIpv6InCidr(addressValue, '2000::', 3)) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
return blockedIpv6Cidrs.some(([network, prefixLength]) => isIpv6InCidr(addressValue, network, prefixLength));
|
|
71
|
+
}
|
|
72
|
+
function isBlockedAddress(address) {
|
|
73
|
+
const family = isIP(address);
|
|
74
|
+
if (family === 4) {
|
|
75
|
+
return isBlockedIpv4(address);
|
|
76
|
+
}
|
|
77
|
+
if (family === 6) {
|
|
78
|
+
return isBlockedIpv6(address);
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
function hostnameFromUrl(url) {
|
|
83
|
+
if (url.hostname.startsWith('[') && url.hostname.endsWith(']')) {
|
|
84
|
+
return url.hostname.slice(1, -1);
|
|
85
|
+
}
|
|
86
|
+
return url.hostname;
|
|
87
|
+
}
|
|
88
|
+
async function resolveAddresses(hostname) {
|
|
89
|
+
const resolved = await lookup(hostname, { all: true, verbatim: true });
|
|
90
|
+
return resolved.map(({ address }) => address);
|
|
91
|
+
}
|
|
92
|
+
function hasForbiddenUrlCharacters(value) {
|
|
93
|
+
return [...value].some((character) => {
|
|
94
|
+
const codePoint = character.codePointAt(0);
|
|
95
|
+
return (codePoint !== undefined && (codePoint <= 0x20 || codePoint === 0x7f || character === '\\'));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Resolves and validates a destination immediately before HTTP dispatch.
|
|
100
|
+
*
|
|
101
|
+
* Only absolute HTTPS URLs without embedded credentials are accepted. Literal
|
|
102
|
+
* addresses are classified without DNS; hostnames are rejected if any
|
|
103
|
+
* resolved IPv4 or IPv6 address is unsafe. Hostname lookup requests every
|
|
104
|
+
* address using Node DNS. Successful results include the vetted addresses for
|
|
105
|
+
* the delivery connector to pin. This internal result must never be exposed
|
|
106
|
+
* through the public API or logs.
|
|
107
|
+
*
|
|
108
|
+
* @param destinationUrl - Persisted destination URL for one dispatch attempt.
|
|
109
|
+
* @returns A canonical URL and vetted addresses or a generic safe failure.
|
|
110
|
+
*/
|
|
111
|
+
export async function resolveDestinationForDispatch(destinationUrl) {
|
|
112
|
+
if (typeof destinationUrl !== 'string' ||
|
|
113
|
+
destinationUrl.length === 0 ||
|
|
114
|
+
destinationUrl !== destinationUrl.trim() ||
|
|
115
|
+
hasForbiddenUrlCharacters(destinationUrl)) {
|
|
116
|
+
return { valid: false, reason: 'invalid_url' };
|
|
117
|
+
}
|
|
118
|
+
let parsedUrl;
|
|
119
|
+
try {
|
|
120
|
+
parsedUrl = new URL(destinationUrl);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return { valid: false, reason: 'invalid_url' };
|
|
124
|
+
}
|
|
125
|
+
if (parsedUrl.protocol !== 'https:') {
|
|
126
|
+
return { valid: false, reason: 'unsupported_protocol' };
|
|
127
|
+
}
|
|
128
|
+
const authority = /^https:\/\/([^/?#]*)/iu.exec(destinationUrl)?.[1];
|
|
129
|
+
if (authority === undefined || authority.length === 0 || parsedUrl.hostname.length === 0) {
|
|
130
|
+
return { valid: false, reason: 'invalid_url' };
|
|
131
|
+
}
|
|
132
|
+
if (authority.includes('@') || parsedUrl.username.length > 0 || parsedUrl.password.length > 0) {
|
|
133
|
+
return { valid: false, reason: 'credentials_not_allowed' };
|
|
134
|
+
}
|
|
135
|
+
const hostname = hostnameFromUrl(parsedUrl);
|
|
136
|
+
const normalizedHostname = hostname.toLowerCase().replace(/\.$/u, '');
|
|
137
|
+
if (normalizedHostname === 'localhost' || normalizedHostname.endsWith('.localhost')) {
|
|
138
|
+
return { valid: false, reason: 'unsafe_address' };
|
|
139
|
+
}
|
|
140
|
+
const addressFamily = isIP(hostname);
|
|
141
|
+
if (addressFamily !== 0) {
|
|
142
|
+
return isBlockedAddress(hostname)
|
|
143
|
+
? { valid: false, reason: 'unsafe_address' }
|
|
144
|
+
: { valid: true, url: parsedUrl.toString(), hostname, addresses: [hostname] };
|
|
145
|
+
}
|
|
146
|
+
let addresses;
|
|
147
|
+
try {
|
|
148
|
+
addresses = await resolveAddresses(hostname);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return { valid: false, reason: 'dns_resolution_failed' };
|
|
152
|
+
}
|
|
153
|
+
if (addresses.length === 0) {
|
|
154
|
+
return { valid: false, reason: 'dns_resolution_failed' };
|
|
155
|
+
}
|
|
156
|
+
if (addresses.some(isBlockedAddress)) {
|
|
157
|
+
return { valid: false, reason: 'unsafe_address' };
|
|
158
|
+
}
|
|
159
|
+
return { valid: true, url: parsedUrl.toString(), hostname, addresses };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Validates a destination URL before it can be persisted.
|
|
163
|
+
*
|
|
164
|
+
* @param destinationUrl - Untrusted destination URL supplied by a caller or configuration.
|
|
165
|
+
* @returns A canonical URL on success or a generic safe failure reason.
|
|
166
|
+
*/
|
|
167
|
+
export async function validateDestinationUrl(destinationUrl) {
|
|
168
|
+
const result = await resolveDestinationForDispatch(destinationUrl);
|
|
169
|
+
if (!result.valid) {
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
return { valid: true, url: result.url };
|
|
173
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type DestinationStream, type Logger } from 'pino';
|
|
2
|
+
export declare const LOG_LEVELS: readonly ["fatal", "error", "warn", "info", "debug", "trace", "silent"];
|
|
3
|
+
export type LogLevel = (typeof LOG_LEVELS)[number];
|
|
4
|
+
export interface StructuredLoggerOptions {
|
|
5
|
+
readonly level: LogLevel;
|
|
6
|
+
readonly destination?: DestinationStream;
|
|
7
|
+
}
|
|
8
|
+
/** Resolves and validates a package option, environment setting, or default. */
|
|
9
|
+
export declare function resolveLogLevel(explicit: string | undefined, environment: string | undefined): LogLevel;
|
|
10
|
+
/** Creates the Pino-backed JSON logger with built-in sensitive-field redaction. */
|
|
11
|
+
export declare function createStructuredLogger(options: StructuredLoggerOptions): Logger;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import pino, {} from 'pino';
|
|
2
|
+
export const LOG_LEVELS = ['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent'];
|
|
3
|
+
const REDACT_PATHS = [
|
|
4
|
+
'Authorization',
|
|
5
|
+
'authorization',
|
|
6
|
+
'headers.Authorization',
|
|
7
|
+
'headers.authorization',
|
|
8
|
+
'headers["X-API-Key"]',
|
|
9
|
+
'headers["x-api-key"]',
|
|
10
|
+
'req.headers.Authorization',
|
|
11
|
+
'req.headers.authorization',
|
|
12
|
+
'req.headers["X-API-Key"]',
|
|
13
|
+
'req.headers["x-api-key"]',
|
|
14
|
+
'request.headers.Authorization',
|
|
15
|
+
'request.headers.authorization',
|
|
16
|
+
'request.headers["X-API-Key"]',
|
|
17
|
+
'request.headers["x-api-key"]',
|
|
18
|
+
'["x-api-key"]',
|
|
19
|
+
'["X-API-Key"]',
|
|
20
|
+
'apiKey',
|
|
21
|
+
'api_key',
|
|
22
|
+
'API_KEY',
|
|
23
|
+
'hmacSecret',
|
|
24
|
+
'hmac_secret',
|
|
25
|
+
'HMAC_SECRET',
|
|
26
|
+
'secret',
|
|
27
|
+
'payload',
|
|
28
|
+
'body',
|
|
29
|
+
'request.body',
|
|
30
|
+
'request.payload',
|
|
31
|
+
'event.payload',
|
|
32
|
+
'event.headers',
|
|
33
|
+
'eventData.payload',
|
|
34
|
+
'eventData.headers',
|
|
35
|
+
];
|
|
36
|
+
/** Resolves and validates a package option, environment setting, or default. */
|
|
37
|
+
export function resolveLogLevel(explicit, environment) {
|
|
38
|
+
const candidate = explicit ?? environment ?? 'info';
|
|
39
|
+
if (LOG_LEVELS.includes(candidate))
|
|
40
|
+
return candidate;
|
|
41
|
+
throw new TypeError(`Unsupported log level "${candidate}".`);
|
|
42
|
+
}
|
|
43
|
+
/** Creates the Pino-backed JSON logger with built-in sensitive-field redaction. */
|
|
44
|
+
export function createStructuredLogger(options) {
|
|
45
|
+
const pinoOptions = {
|
|
46
|
+
level: options.level,
|
|
47
|
+
redact: { paths: REDACT_PATHS, remove: true },
|
|
48
|
+
};
|
|
49
|
+
return options.destination === undefined
|
|
50
|
+
? pino(pinoOptions)
|
|
51
|
+
: pino(pinoOptions, options.destination);
|
|
52
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { SQLInputValue } from 'node:sqlite';
|
|
2
|
+
/** A prepared SQLite statement surface shared by supported SQLite drivers. */
|
|
3
|
+
export interface SqliteMigrationStatement {
|
|
4
|
+
/** Returns all rows produced by a query; row shape is validated by the runner. */
|
|
5
|
+
all(...parameters: SQLInputValue[]): unknown;
|
|
6
|
+
/** Returns one row produced by a query; row shape is validated by the runner. */
|
|
7
|
+
get(...parameters: SQLInputValue[]): unknown;
|
|
8
|
+
/** Executes a statement without returning rows. */
|
|
9
|
+
run(...parameters: SQLInputValue[]): unknown;
|
|
10
|
+
}
|
|
11
|
+
/** The minimal synchronous SQLite connection surface needed to apply migrations. */
|
|
12
|
+
export interface SqliteMigrationConnection {
|
|
13
|
+
/** Executes one or more SQL statements on the current connection. */
|
|
14
|
+
exec(sql: string): void;
|
|
15
|
+
/** Prepares a SQL statement for parameterized execution. */
|
|
16
|
+
prepare(sql: string): SqliteMigrationStatement;
|
|
17
|
+
}
|
|
18
|
+
/** Options for applying migrations with a synchronous SQLite connection. */
|
|
19
|
+
export interface SqliteMigrationOptions {
|
|
20
|
+
/** Selects the SQLite execution and transaction policy. */
|
|
21
|
+
readonly dialect: 'sqlite';
|
|
22
|
+
/** Connection whose transaction is exclusively controlled during migration. */
|
|
23
|
+
readonly connection: SqliteMigrationConnection;
|
|
24
|
+
/** Directory containing versioned SQLite SQL files; defaults to the bundled directory. */
|
|
25
|
+
readonly migrationDirectory?: string;
|
|
26
|
+
}
|
|
27
|
+
/** Options for applying the bundled SQLite migrations. */
|
|
28
|
+
export type MigrationRunnerOptions = SqliteMigrationOptions;
|
|
29
|
+
/** An explicit failure raised when migration discovery, validation, or execution fails. */
|
|
30
|
+
export declare class MigrationError extends Error {
|
|
31
|
+
/**
|
|
32
|
+
* Creates a migration failure with an optional originating error.
|
|
33
|
+
*
|
|
34
|
+
* @param message - Actionable description of the migration failure.
|
|
35
|
+
* @param cause - Original filesystem or database error, when available.
|
|
36
|
+
*/
|
|
37
|
+
constructor(message: string, cause?: unknown);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Applies the forward-only SQL migrations for SQLite.
|
|
41
|
+
*
|
|
42
|
+
* Migration files are applied in lexical filename order, hashed with SHA-256,
|
|
43
|
+
* and recorded in `schema_migrations`. Existing records must match both the
|
|
44
|
+
* checked-in migration file and checksum. All pending SQLite migrations run
|
|
45
|
+
* in one transaction.
|
|
46
|
+
*
|
|
47
|
+
* @param options - Dialect, connection, and optional migration directory.
|
|
48
|
+
* @returns Versions newly applied by this invocation, in application order.
|
|
49
|
+
* @throws {@link MigrationError} when files are invalid, drift is detected, or execution fails.
|
|
50
|
+
*/
|
|
51
|
+
export declare function runMigrations(options: MigrationRunnerOptions): Promise<readonly string[]>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
/** An explicit failure raised when migration discovery, validation, or execution fails. */
|
|
6
|
+
export class MigrationError extends Error {
|
|
7
|
+
/**
|
|
8
|
+
* Creates a migration failure with an optional originating error.
|
|
9
|
+
*
|
|
10
|
+
* @param message - Actionable description of the migration failure.
|
|
11
|
+
* @param cause - Original filesystem or database error, when available.
|
|
12
|
+
*/
|
|
13
|
+
constructor(message, cause) {
|
|
14
|
+
super(message, { cause });
|
|
15
|
+
this.name = 'MigrationError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Applies the forward-only SQL migrations for SQLite.
|
|
20
|
+
*
|
|
21
|
+
* Migration files are applied in lexical filename order, hashed with SHA-256,
|
|
22
|
+
* and recorded in `schema_migrations`. Existing records must match both the
|
|
23
|
+
* checked-in migration file and checksum. All pending SQLite migrations run
|
|
24
|
+
* in one transaction.
|
|
25
|
+
*
|
|
26
|
+
* @param options - Dialect, connection, and optional migration directory.
|
|
27
|
+
* @returns Versions newly applied by this invocation, in application order.
|
|
28
|
+
* @throws {@link MigrationError} when files are invalid, drift is detected, or execution fails.
|
|
29
|
+
*/
|
|
30
|
+
export async function runMigrations(options) {
|
|
31
|
+
const directory = options.migrationDirectory ?? fileURLToPath(new URL('./sql/sqlite/', import.meta.url));
|
|
32
|
+
const files = await loadMigrationFiles(directory);
|
|
33
|
+
return runSqliteMigrations(options.connection, files);
|
|
34
|
+
}
|
|
35
|
+
async function loadMigrationFiles(directory) {
|
|
36
|
+
let entries;
|
|
37
|
+
try {
|
|
38
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
39
|
+
}
|
|
40
|
+
catch (cause) {
|
|
41
|
+
throw new MigrationError(`Unable to read migration directory "${directory}".`, cause);
|
|
42
|
+
}
|
|
43
|
+
const names = entries
|
|
44
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.sql'))
|
|
45
|
+
.map((entry) => entry.name)
|
|
46
|
+
.sort();
|
|
47
|
+
const versions = new Set();
|
|
48
|
+
const ordinals = new Set();
|
|
49
|
+
const files = [];
|
|
50
|
+
for (const name of names) {
|
|
51
|
+
const match = /^(\d+_[a-z0-9][a-z0-9_-]*)\.sql$/.exec(name);
|
|
52
|
+
if (match === null) {
|
|
53
|
+
throw new MigrationError(`Invalid migration filename "${name}".`);
|
|
54
|
+
}
|
|
55
|
+
const version = match[1];
|
|
56
|
+
if (version === undefined || versions.has(version)) {
|
|
57
|
+
throw new MigrationError(`Duplicate or invalid migration version in "${name}".`);
|
|
58
|
+
}
|
|
59
|
+
const ordinal = BigInt(version.slice(0, version.indexOf('_'))).toString();
|
|
60
|
+
if (ordinals.has(ordinal)) {
|
|
61
|
+
throw new MigrationError(`Duplicate migration sequence number in "${name}".`);
|
|
62
|
+
}
|
|
63
|
+
versions.add(version);
|
|
64
|
+
ordinals.add(ordinal);
|
|
65
|
+
let sql;
|
|
66
|
+
try {
|
|
67
|
+
sql = await readFile(join(directory, name), 'utf8');
|
|
68
|
+
}
|
|
69
|
+
catch (cause) {
|
|
70
|
+
throw new MigrationError(`Unable to read migration file "${name}".`, cause);
|
|
71
|
+
}
|
|
72
|
+
if (sql.trim().length === 0) {
|
|
73
|
+
throw new MigrationError(`Migration "${version}" is empty.`);
|
|
74
|
+
}
|
|
75
|
+
files.push({
|
|
76
|
+
version,
|
|
77
|
+
checksum: createHash('sha256').update(sql, 'utf8').digest('hex'),
|
|
78
|
+
sql,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return files.sort((left, right) => {
|
|
82
|
+
const leftOrdinal = BigInt(left.version.slice(0, left.version.indexOf('_')));
|
|
83
|
+
const rightOrdinal = BigInt(right.version.slice(0, right.version.indexOf('_')));
|
|
84
|
+
return leftOrdinal < rightOrdinal ? -1 : leftOrdinal > rightOrdinal ? 1 : 0;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function readAppliedMigrations(rows) {
|
|
88
|
+
if (!Array.isArray(rows)) {
|
|
89
|
+
throw new MigrationError('Migration ledger query returned an invalid result.');
|
|
90
|
+
}
|
|
91
|
+
return rows.map((row) => {
|
|
92
|
+
if (typeof row !== 'object' ||
|
|
93
|
+
row === null ||
|
|
94
|
+
!('version' in row) ||
|
|
95
|
+
!('checksum' in row) ||
|
|
96
|
+
typeof row.version !== 'string' ||
|
|
97
|
+
typeof row.checksum !== 'string') {
|
|
98
|
+
throw new MigrationError('Migration ledger contains an invalid record.');
|
|
99
|
+
}
|
|
100
|
+
return { version: row.version, checksum: row.checksum };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function getPendingMigrations(files, applied) {
|
|
104
|
+
const byVersion = new Map(files.map((file) => [file.version, file]));
|
|
105
|
+
const appliedVersions = new Set();
|
|
106
|
+
for (const record of applied) {
|
|
107
|
+
const file = byVersion.get(record.version);
|
|
108
|
+
if (file === undefined) {
|
|
109
|
+
throw new MigrationError(`Applied migration "${record.version}" is missing from the migration directory.`);
|
|
110
|
+
}
|
|
111
|
+
if (file.checksum !== record.checksum) {
|
|
112
|
+
throw new MigrationError(`Checksum mismatch for applied migration "${record.version}".`);
|
|
113
|
+
}
|
|
114
|
+
appliedVersions.add(record.version);
|
|
115
|
+
}
|
|
116
|
+
return files.filter((file) => !appliedVersions.has(file.version));
|
|
117
|
+
}
|
|
118
|
+
async function runSqliteMigrations(connection, files) {
|
|
119
|
+
let transactionStarted = false;
|
|
120
|
+
try {
|
|
121
|
+
connection.exec('BEGIN IMMEDIATE');
|
|
122
|
+
transactionStarted = true;
|
|
123
|
+
connection.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
124
|
+
version TEXT PRIMARY KEY,
|
|
125
|
+
checksum TEXT NOT NULL,
|
|
126
|
+
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
127
|
+
)`);
|
|
128
|
+
const applied = readAppliedMigrations(connection.prepare('SELECT version, checksum FROM schema_migrations').all());
|
|
129
|
+
const pending = getPendingMigrations(files, applied);
|
|
130
|
+
for (const migration of pending) {
|
|
131
|
+
connection.exec(migration.sql);
|
|
132
|
+
connection
|
|
133
|
+
.prepare('INSERT INTO schema_migrations (version, checksum) VALUES (?, ?)')
|
|
134
|
+
.run(migration.version, migration.checksum);
|
|
135
|
+
}
|
|
136
|
+
connection.exec('COMMIT');
|
|
137
|
+
return pending.map((migration) => migration.version);
|
|
138
|
+
}
|
|
139
|
+
catch (cause) {
|
|
140
|
+
if (transactionStarted) {
|
|
141
|
+
try {
|
|
142
|
+
connection.exec('ROLLBACK');
|
|
143
|
+
}
|
|
144
|
+
catch (rollbackCause) {
|
|
145
|
+
throw new MigrationError('Migration failed and SQLite rollback also failed.', new AggregateError([cause, rollbackCause]));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
throw toMigrationError('SQLite migration failed.', cause);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function toMigrationError(message, cause) {
|
|
152
|
+
if (cause instanceof MigrationError)
|
|
153
|
+
return cause;
|
|
154
|
+
return new MigrationError(message, cause);
|
|
155
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS webhook_events (
|
|
2
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
3
|
+
idempotency_key TEXT NOT NULL,
|
|
4
|
+
client_id TEXT NOT NULL,
|
|
5
|
+
destination_url TEXT NOT NULL,
|
|
6
|
+
payload TEXT NOT NULL CHECK (json_valid(payload)),
|
|
7
|
+
metadata TEXT NOT NULL CHECK (
|
|
8
|
+
json_valid(metadata) AND json_type(metadata) = 'object'
|
|
9
|
+
),
|
|
10
|
+
headers TEXT NOT NULL CHECK (
|
|
11
|
+
json_valid(headers) AND json_type(headers) = 'object'
|
|
12
|
+
),
|
|
13
|
+
status TEXT NOT NULL CHECK (
|
|
14
|
+
status IN ('pending', 'processing', 'delivered', 'retry', 'failed')
|
|
15
|
+
),
|
|
16
|
+
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
|
17
|
+
next_retry_at TEXT,
|
|
18
|
+
locked_at TEXT,
|
|
19
|
+
locked_by TEXT,
|
|
20
|
+
created_at TEXT NOT NULL,
|
|
21
|
+
updated_at TEXT NOT NULL,
|
|
22
|
+
CONSTRAINT uq_client_idempotency UNIQUE (client_id, idempotency_key),
|
|
23
|
+
CONSTRAINT chk_webhook_events_lease CHECK (
|
|
24
|
+
(status = 'processing' AND locked_at IS NOT NULL AND locked_by IS NOT NULL)
|
|
25
|
+
OR
|
|
26
|
+
(status <> 'processing' AND locked_at IS NULL AND locked_by IS NULL)
|
|
27
|
+
)
|
|
28
|
+
) STRICT;
|
|
29
|
+
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_webhook_events_due
|
|
31
|
+
ON webhook_events (status, next_retry_at);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS webhook_event_attempts (
|
|
34
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
35
|
+
event_id TEXT NOT NULL,
|
|
36
|
+
attempt_number INTEGER NOT NULL CHECK (attempt_number > 0),
|
|
37
|
+
attempted_at TEXT NOT NULL,
|
|
38
|
+
status_code INTEGER CHECK (status_code IS NULL OR status_code BETWEEN 100 AND 599),
|
|
39
|
+
response_snippet TEXT CHECK (
|
|
40
|
+
response_snippet IS NULL OR length(CAST(response_snippet AS BLOB)) <= 1024
|
|
41
|
+
),
|
|
42
|
+
error TEXT,
|
|
43
|
+
latency_ms INTEGER CHECK (latency_ms IS NULL OR latency_ms >= 0),
|
|
44
|
+
outcome TEXT NOT NULL CHECK (
|
|
45
|
+
outcome IN ('success', 'retry_scheduled', 'exhausted')
|
|
46
|
+
),
|
|
47
|
+
CONSTRAINT fk_webhook_event_attempts_event
|
|
48
|
+
FOREIGN KEY (event_id) REFERENCES webhook_events (id) ON DELETE CASCADE
|
|
49
|
+
) STRICT;
|
|
50
|
+
|
|
51
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_webhook_event_attempts_event_number
|
|
52
|
+
ON webhook_event_attempts (event_id, attempt_number);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { AttemptRecord, WebhookEvent } from '../core/types.js';
|
|
2
|
+
import type { ClaimDueEventsInput, DatabaseAdapter, EventPage, EventUpsertResult, ListEventsInput, RecordAttemptAndTransitionInput, RecoverStaleLeasesInput, ReleaseEventLeaseInput } from '../core/ports.js';
|
|
3
|
+
/**
|
|
4
|
+
* SQLite implementation of the driver-neutral event persistence contract.
|
|
5
|
+
*
|
|
6
|
+
* Create instances with {@link SqliteDatabaseAdapter.open}; the returned
|
|
7
|
+
* adapter owns its database connection and must be closed by its owner.
|
|
8
|
+
*/
|
|
9
|
+
export declare class SqliteDatabaseAdapter implements DatabaseAdapter {
|
|
10
|
+
private readonly database;
|
|
11
|
+
private constructor();
|
|
12
|
+
/**
|
|
13
|
+
* Opens and initializes a SQLite database before exposing the adapter.
|
|
14
|
+
*
|
|
15
|
+
* Initialization enables WAL, a 5-second busy timeout, foreign-key checks,
|
|
16
|
+
* and applies the bundled forward-only migrations. The connection is closed
|
|
17
|
+
* and the original error is propagated if initialization fails.
|
|
18
|
+
*
|
|
19
|
+
* @param filename - SQLite database file path; use a file for WAL support.
|
|
20
|
+
* @returns An initialized adapter implementing {@link DatabaseAdapter}.
|
|
21
|
+
* @throws When the database cannot be opened, configured, or migrated.
|
|
22
|
+
*/
|
|
23
|
+
static open(filename: string): Promise<SqliteDatabaseAdapter>;
|
|
24
|
+
/**
|
|
25
|
+
* Closes the SQLite connection owned by this adapter.
|
|
26
|
+
*
|
|
27
|
+
* @returns Nothing.
|
|
28
|
+
* @throws When the underlying database cannot be closed.
|
|
29
|
+
*/
|
|
30
|
+
close(): void;
|
|
31
|
+
/** @inheritdoc */
|
|
32
|
+
upsertEvent(event: WebhookEvent): Promise<EventUpsertResult>;
|
|
33
|
+
/** @inheritdoc */
|
|
34
|
+
claimDueEvents(input: ClaimDueEventsInput): Promise<readonly WebhookEvent[]>;
|
|
35
|
+
/** @inheritdoc */
|
|
36
|
+
recoverStaleLeases(input: RecoverStaleLeasesInput): Promise<number>;
|
|
37
|
+
/** @inheritdoc */
|
|
38
|
+
releaseEventLease(input: ReleaseEventLeaseInput): Promise<boolean>;
|
|
39
|
+
/** @inheritdoc */
|
|
40
|
+
recordAttemptAndTransition(input: RecordAttemptAndTransitionInput): Promise<boolean>;
|
|
41
|
+
/** @inheritdoc */
|
|
42
|
+
findEventById(eventId: string, clientId: string): Promise<WebhookEvent | null>;
|
|
43
|
+
/** @inheritdoc */
|
|
44
|
+
findEventByIdempotencyKey(clientId: string, idempotencyKey: string): Promise<WebhookEvent | null>;
|
|
45
|
+
/** @inheritdoc */
|
|
46
|
+
listEvents(input: ListEventsInput): Promise<EventPage>;
|
|
47
|
+
/** @inheritdoc */
|
|
48
|
+
listAttempts(eventId: string, clientId: string): Promise<readonly AttemptRecord[]>;
|
|
49
|
+
}
|
|
50
|
+
/** Compares claimed events by creation time, then by their unique id. */
|
|
51
|
+
export declare function compareClaimedEvents(left: WebhookEvent, right: WebhookEvent): number;
|