@stepflowjs/trigger-webhook 0.0.1
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/dist/index.d.ts +146 -0
- package/dist/index.js +271 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Trigger, TriggerHandler } from '@stepflowjs/core';
|
|
2
|
+
|
|
3
|
+
type SignatureAlgorithm = "sha256" | "sha1" | "sha512";
|
|
4
|
+
interface WebhookTriggerConfig {
|
|
5
|
+
/** The webhook endpoint path */
|
|
6
|
+
path: string;
|
|
7
|
+
/** Secret for signature verification */
|
|
8
|
+
secret: string;
|
|
9
|
+
/** Header name containing the signature (default: 'x-webhook-signature') */
|
|
10
|
+
signatureHeader?: string;
|
|
11
|
+
/** Signing algorithm (default: 'sha256') */
|
|
12
|
+
algorithm?: SignatureAlgorithm;
|
|
13
|
+
/** Signature prefix like 'sha256=' (optional) */
|
|
14
|
+
signaturePrefix?: string;
|
|
15
|
+
/** Header name for timestamp (for replay attack prevention) */
|
|
16
|
+
timestampHeader?: string;
|
|
17
|
+
/** Maximum age in seconds for timestamp validation (default: 300) */
|
|
18
|
+
timestampTolerance?: number;
|
|
19
|
+
}
|
|
20
|
+
interface WebhookTriggerResponse {
|
|
21
|
+
success: boolean;
|
|
22
|
+
eventId: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Webhook trigger for Stepflow workflows with signature verification
|
|
26
|
+
*
|
|
27
|
+
* Supports multiple webhook provider patterns:
|
|
28
|
+
* - GitHub style: `sha256=<hex>`
|
|
29
|
+
* - Stripe style: `t=timestamp,v1=signature`
|
|
30
|
+
* - Generic HMAC
|
|
31
|
+
*
|
|
32
|
+
* Includes replay attack prevention via timestamp validation.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* // GitHub webhook
|
|
37
|
+
* const githubTrigger = new WebhookTrigger({
|
|
38
|
+
* path: '/webhooks/github',
|
|
39
|
+
* secret: process.env.GITHUB_WEBHOOK_SECRET,
|
|
40
|
+
* signatureHeader: 'x-hub-signature-256',
|
|
41
|
+
* algorithm: 'sha256',
|
|
42
|
+
* signaturePrefix: 'sha256=',
|
|
43
|
+
* });
|
|
44
|
+
*
|
|
45
|
+
* // Stripe webhook
|
|
46
|
+
* const stripeTrigger = new WebhookTrigger({
|
|
47
|
+
* path: '/webhooks/stripe',
|
|
48
|
+
* secret: process.env.STRIPE_WEBHOOK_SECRET,
|
|
49
|
+
* signatureHeader: 'stripe-signature',
|
|
50
|
+
* algorithm: 'sha256',
|
|
51
|
+
* timestampHeader: 'stripe-signature',
|
|
52
|
+
* timestampTolerance: 300,
|
|
53
|
+
* });
|
|
54
|
+
*
|
|
55
|
+
* await githubTrigger.start(async (event) => {
|
|
56
|
+
* await stepflow.trigger('github-webhook', event.data);
|
|
57
|
+
* });
|
|
58
|
+
*
|
|
59
|
+
* // In your framework adapter:
|
|
60
|
+
* app.post('/webhooks/github', async (req) => {
|
|
61
|
+
* const response = await githubTrigger.handleRequest(req);
|
|
62
|
+
* return response;
|
|
63
|
+
* });
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
declare class WebhookTrigger implements Trigger<WebhookTriggerConfig> {
|
|
67
|
+
readonly config: WebhookTriggerConfig;
|
|
68
|
+
readonly type = "webhook";
|
|
69
|
+
private handler?;
|
|
70
|
+
constructor(config: WebhookTriggerConfig);
|
|
71
|
+
/**
|
|
72
|
+
* Start the trigger with a handler function
|
|
73
|
+
* @param handler Function to call when webhook requests are received
|
|
74
|
+
*/
|
|
75
|
+
start(handler: TriggerHandler): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Stop the trigger
|
|
78
|
+
*/
|
|
79
|
+
stop(): Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* Health check - returns true if handler is registered
|
|
82
|
+
*/
|
|
83
|
+
healthCheck(): Promise<boolean>;
|
|
84
|
+
/**
|
|
85
|
+
* Handle an incoming webhook request
|
|
86
|
+
*
|
|
87
|
+
* This method should be called by framework adapters to process webhook requests.
|
|
88
|
+
* It validates the signature, checks for replay attacks, creates a trigger event,
|
|
89
|
+
* and invokes the registered handler.
|
|
90
|
+
*
|
|
91
|
+
* @param request Web standard Request object
|
|
92
|
+
* @returns Web standard Response object
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```typescript
|
|
96
|
+
* // Hono adapter
|
|
97
|
+
* app.post('/webhook', async (c) => {
|
|
98
|
+
* const response = await trigger.handleRequest(c.req.raw);
|
|
99
|
+
* return response;
|
|
100
|
+
* });
|
|
101
|
+
*
|
|
102
|
+
* // Express adapter
|
|
103
|
+
* app.post('/webhook', async (req, res) => {
|
|
104
|
+
* const request = new Request(`http://localhost${req.url}`, {
|
|
105
|
+
* method: req.method,
|
|
106
|
+
* headers: req.headers,
|
|
107
|
+
* body: JSON.stringify(req.body),
|
|
108
|
+
* });
|
|
109
|
+
* const response = await trigger.handleRequest(request);
|
|
110
|
+
* res.status(response.status).json(await response.json());
|
|
111
|
+
* });
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
handleRequest(request: Request): Promise<Response>;
|
|
115
|
+
/**
|
|
116
|
+
* Validate webhook signature using Web Crypto API
|
|
117
|
+
* Supports multiple signature formats:
|
|
118
|
+
* - GitHub: sha256=<hex>
|
|
119
|
+
* - Stripe: t=timestamp,v1=signature
|
|
120
|
+
* - Generic: <hex>
|
|
121
|
+
* @private
|
|
122
|
+
*/
|
|
123
|
+
private validateSignature;
|
|
124
|
+
/**
|
|
125
|
+
* Verify HMAC signature using Web Crypto API
|
|
126
|
+
* @private
|
|
127
|
+
*/
|
|
128
|
+
private verifyHmac;
|
|
129
|
+
/**
|
|
130
|
+
* Map algorithm name to Web Crypto API hash algorithm
|
|
131
|
+
* @private
|
|
132
|
+
*/
|
|
133
|
+
private getHashAlgorithm;
|
|
134
|
+
/**
|
|
135
|
+
* Constant-time string comparison to prevent timing attacks
|
|
136
|
+
* @private
|
|
137
|
+
*/
|
|
138
|
+
private constantTimeCompare;
|
|
139
|
+
/**
|
|
140
|
+
* Validate timestamp to prevent replay attacks
|
|
141
|
+
* @private
|
|
142
|
+
*/
|
|
143
|
+
private validateTimestamp;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export { type SignatureAlgorithm, WebhookTrigger, type WebhookTriggerConfig, type WebhookTriggerResponse };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var WebhookTrigger = class {
|
|
3
|
+
constructor(config) {
|
|
4
|
+
this.config = config;
|
|
5
|
+
this.config.signatureHeader = config.signatureHeader || "x-webhook-signature";
|
|
6
|
+
this.config.algorithm = config.algorithm || "sha256";
|
|
7
|
+
this.config.timestampTolerance = config.timestampTolerance ?? 300;
|
|
8
|
+
}
|
|
9
|
+
type = "webhook";
|
|
10
|
+
handler;
|
|
11
|
+
/**
|
|
12
|
+
* Start the trigger with a handler function
|
|
13
|
+
* @param handler Function to call when webhook requests are received
|
|
14
|
+
*/
|
|
15
|
+
async start(handler) {
|
|
16
|
+
this.handler = handler;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Stop the trigger
|
|
20
|
+
*/
|
|
21
|
+
async stop() {
|
|
22
|
+
this.handler = void 0;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Health check - returns true if handler is registered
|
|
26
|
+
*/
|
|
27
|
+
async healthCheck() {
|
|
28
|
+
return this.handler !== void 0;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Handle an incoming webhook request
|
|
32
|
+
*
|
|
33
|
+
* This method should be called by framework adapters to process webhook requests.
|
|
34
|
+
* It validates the signature, checks for replay attacks, creates a trigger event,
|
|
35
|
+
* and invokes the registered handler.
|
|
36
|
+
*
|
|
37
|
+
* @param request Web standard Request object
|
|
38
|
+
* @returns Web standard Response object
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* // Hono adapter
|
|
43
|
+
* app.post('/webhook', async (c) => {
|
|
44
|
+
* const response = await trigger.handleRequest(c.req.raw);
|
|
45
|
+
* return response;
|
|
46
|
+
* });
|
|
47
|
+
*
|
|
48
|
+
* // Express adapter
|
|
49
|
+
* app.post('/webhook', async (req, res) => {
|
|
50
|
+
* const request = new Request(`http://localhost${req.url}`, {
|
|
51
|
+
* method: req.method,
|
|
52
|
+
* headers: req.headers,
|
|
53
|
+
* body: JSON.stringify(req.body),
|
|
54
|
+
* });
|
|
55
|
+
* const response = await trigger.handleRequest(request);
|
|
56
|
+
* res.status(response.status).json(await response.json());
|
|
57
|
+
* });
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
async handleRequest(request) {
|
|
61
|
+
if (!this.handler) {
|
|
62
|
+
return new Response(JSON.stringify({ error: "Trigger not started" }), {
|
|
63
|
+
status: 503,
|
|
64
|
+
headers: { "Content-Type": "application/json" }
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const bodyText = await request.text();
|
|
69
|
+
let data;
|
|
70
|
+
const contentType = request.headers.get("content-type") || "";
|
|
71
|
+
if (contentType.includes("application/json")) {
|
|
72
|
+
data = JSON.parse(bodyText);
|
|
73
|
+
} else if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
74
|
+
const params = new URLSearchParams(bodyText);
|
|
75
|
+
data = Object.fromEntries(params.entries());
|
|
76
|
+
} else {
|
|
77
|
+
data = bodyText;
|
|
78
|
+
}
|
|
79
|
+
const isValid = await this.validateSignature(request, bodyText);
|
|
80
|
+
if (!isValid) {
|
|
81
|
+
return new Response(JSON.stringify({ error: "Invalid signature" }), {
|
|
82
|
+
status: 401,
|
|
83
|
+
headers: { "Content-Type": "application/json" }
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (this.config.timestampHeader) {
|
|
87
|
+
const isTimestampValid = this.validateTimestamp(request);
|
|
88
|
+
if (!isTimestampValid) {
|
|
89
|
+
return new Response(
|
|
90
|
+
JSON.stringify({ error: "Request timestamp too old or invalid" }),
|
|
91
|
+
{
|
|
92
|
+
status: 401,
|
|
93
|
+
headers: { "Content-Type": "application/json" }
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const eventId = crypto.randomUUID();
|
|
99
|
+
const event = {
|
|
100
|
+
id: eventId,
|
|
101
|
+
type: this.type,
|
|
102
|
+
source: `webhook ${this.config.path}`,
|
|
103
|
+
data,
|
|
104
|
+
metadata: {
|
|
105
|
+
path: this.config.path,
|
|
106
|
+
headers: Object.fromEntries(request.headers.entries())
|
|
107
|
+
},
|
|
108
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
109
|
+
};
|
|
110
|
+
await this.handler(event);
|
|
111
|
+
const response = {
|
|
112
|
+
success: true,
|
|
113
|
+
eventId
|
|
114
|
+
};
|
|
115
|
+
return new Response(JSON.stringify(response), {
|
|
116
|
+
status: 200,
|
|
117
|
+
headers: { "Content-Type": "application/json" }
|
|
118
|
+
});
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return new Response(
|
|
121
|
+
JSON.stringify({
|
|
122
|
+
error: "Internal server error",
|
|
123
|
+
message: error.message
|
|
124
|
+
}),
|
|
125
|
+
{
|
|
126
|
+
status: 500,
|
|
127
|
+
headers: { "Content-Type": "application/json" }
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Validate webhook signature using Web Crypto API
|
|
134
|
+
* Supports multiple signature formats:
|
|
135
|
+
* - GitHub: sha256=<hex>
|
|
136
|
+
* - Stripe: t=timestamp,v1=signature
|
|
137
|
+
* - Generic: <hex>
|
|
138
|
+
* @private
|
|
139
|
+
*/
|
|
140
|
+
async validateSignature(request, payload) {
|
|
141
|
+
const signatureHeader = request.headers.get(this.config.signatureHeader);
|
|
142
|
+
if (!signatureHeader) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
let signature;
|
|
147
|
+
let timestamp;
|
|
148
|
+
if (signatureHeader.includes("t=") && signatureHeader.includes("v1=")) {
|
|
149
|
+
const parts = signatureHeader.split(",");
|
|
150
|
+
const tPart = parts.find((p) => p.startsWith("t="));
|
|
151
|
+
const v1Part = parts.find((p) => p.startsWith("v1="));
|
|
152
|
+
if (!tPart || !v1Part) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
timestamp = parseInt(tPart.split("=")[1], 10);
|
|
156
|
+
signature = v1Part.split("=")[1];
|
|
157
|
+
const signedPayload = `${timestamp}.${payload}`;
|
|
158
|
+
return await this.verifyHmac(signedPayload, signature);
|
|
159
|
+
}
|
|
160
|
+
if (this.config.signaturePrefix && signatureHeader.startsWith(this.config.signaturePrefix)) {
|
|
161
|
+
signature = signatureHeader.substring(
|
|
162
|
+
this.config.signaturePrefix.length
|
|
163
|
+
);
|
|
164
|
+
} else if (signatureHeader.includes("=")) {
|
|
165
|
+
signature = signatureHeader.split("=")[1];
|
|
166
|
+
} else {
|
|
167
|
+
signature = signatureHeader;
|
|
168
|
+
}
|
|
169
|
+
return await this.verifyHmac(payload, signature);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.error("Signature validation error:", error);
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Verify HMAC signature using Web Crypto API
|
|
177
|
+
* @private
|
|
178
|
+
*/
|
|
179
|
+
async verifyHmac(payload, expectedSignature) {
|
|
180
|
+
const encoder = new TextEncoder();
|
|
181
|
+
const keyData = encoder.encode(this.config.secret);
|
|
182
|
+
const messageData = encoder.encode(payload);
|
|
183
|
+
const hashAlgorithm = this.getHashAlgorithm(this.config.algorithm);
|
|
184
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
185
|
+
"raw",
|
|
186
|
+
keyData,
|
|
187
|
+
{
|
|
188
|
+
name: "HMAC",
|
|
189
|
+
hash: hashAlgorithm
|
|
190
|
+
},
|
|
191
|
+
false,
|
|
192
|
+
["sign"]
|
|
193
|
+
);
|
|
194
|
+
const signatureBuffer = await crypto.subtle.sign(
|
|
195
|
+
"HMAC",
|
|
196
|
+
cryptoKey,
|
|
197
|
+
messageData
|
|
198
|
+
);
|
|
199
|
+
const hashArray = Array.from(new Uint8Array(signatureBuffer));
|
|
200
|
+
const computedSignature = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
201
|
+
return this.constantTimeCompare(computedSignature, expectedSignature);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Map algorithm name to Web Crypto API hash algorithm
|
|
205
|
+
* @private
|
|
206
|
+
*/
|
|
207
|
+
getHashAlgorithm(algorithm) {
|
|
208
|
+
switch (algorithm) {
|
|
209
|
+
case "sha256":
|
|
210
|
+
return "SHA-256";
|
|
211
|
+
case "sha1":
|
|
212
|
+
return "SHA-1";
|
|
213
|
+
case "sha512":
|
|
214
|
+
return "SHA-512";
|
|
215
|
+
default:
|
|
216
|
+
return "SHA-256";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Constant-time string comparison to prevent timing attacks
|
|
221
|
+
* @private
|
|
222
|
+
*/
|
|
223
|
+
constantTimeCompare(a, b) {
|
|
224
|
+
if (a.length !== b.length) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
let result = 0;
|
|
228
|
+
for (let i = 0; i < a.length; i++) {
|
|
229
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
230
|
+
}
|
|
231
|
+
return result === 0;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Validate timestamp to prevent replay attacks
|
|
235
|
+
* @private
|
|
236
|
+
*/
|
|
237
|
+
validateTimestamp(request) {
|
|
238
|
+
if (!this.config.timestampHeader) {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
const timestampHeader = request.headers.get(this.config.timestampHeader);
|
|
242
|
+
if (!timestampHeader) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
let timestamp;
|
|
247
|
+
if (timestampHeader.includes("t=")) {
|
|
248
|
+
const tPart = timestampHeader.split(",").find((p) => p.startsWith("t="));
|
|
249
|
+
if (!tPart) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
timestamp = parseInt(tPart.split("=")[1], 10);
|
|
253
|
+
} else {
|
|
254
|
+
timestamp = parseInt(timestampHeader, 10);
|
|
255
|
+
}
|
|
256
|
+
if (isNaN(timestamp)) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
260
|
+
const age = now - timestamp;
|
|
261
|
+
return age >= 0 && age <= this.config.timestampTolerance;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
console.error("Timestamp validation error:", error);
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
export {
|
|
269
|
+
WebhookTrigger
|
|
270
|
+
};
|
|
271
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { Trigger, TriggerHandler, TriggerEvent } from \"@stepflowjs/core\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport type SignatureAlgorithm = \"sha256\" | \"sha1\" | \"sha512\";\n\nexport interface WebhookTriggerConfig {\n /** The webhook endpoint path */\n path: string;\n /** Secret for signature verification */\n secret: string;\n /** Header name containing the signature (default: 'x-webhook-signature') */\n signatureHeader?: string;\n /** Signing algorithm (default: 'sha256') */\n algorithm?: SignatureAlgorithm;\n /** Signature prefix like 'sha256=' (optional) */\n signaturePrefix?: string;\n /** Header name for timestamp (for replay attack prevention) */\n timestampHeader?: string;\n /** Maximum age in seconds for timestamp validation (default: 300) */\n timestampTolerance?: number;\n}\n\nexport interface WebhookTriggerResponse {\n success: boolean;\n eventId: string;\n}\n\n// ============================================================================\n// WebhookTrigger Implementation\n// ============================================================================\n\n/**\n * Webhook trigger for Stepflow workflows with signature verification\n *\n * Supports multiple webhook provider patterns:\n * - GitHub style: `sha256=<hex>`\n * - Stripe style: `t=timestamp,v1=signature`\n * - Generic HMAC\n *\n * Includes replay attack prevention via timestamp validation.\n *\n * @example\n * ```typescript\n * // GitHub webhook\n * const githubTrigger = new WebhookTrigger({\n * path: '/webhooks/github',\n * secret: process.env.GITHUB_WEBHOOK_SECRET,\n * signatureHeader: 'x-hub-signature-256',\n * algorithm: 'sha256',\n * signaturePrefix: 'sha256=',\n * });\n *\n * // Stripe webhook\n * const stripeTrigger = new WebhookTrigger({\n * path: '/webhooks/stripe',\n * secret: process.env.STRIPE_WEBHOOK_SECRET,\n * signatureHeader: 'stripe-signature',\n * algorithm: 'sha256',\n * timestampHeader: 'stripe-signature',\n * timestampTolerance: 300,\n * });\n *\n * await githubTrigger.start(async (event) => {\n * await stepflow.trigger('github-webhook', event.data);\n * });\n *\n * // In your framework adapter:\n * app.post('/webhooks/github', async (req) => {\n * const response = await githubTrigger.handleRequest(req);\n * return response;\n * });\n * ```\n */\nexport class WebhookTrigger implements Trigger<WebhookTriggerConfig> {\n readonly type = \"webhook\";\n private handler?: TriggerHandler;\n\n constructor(readonly config: WebhookTriggerConfig) {\n // Set defaults\n this.config.signatureHeader =\n config.signatureHeader || \"x-webhook-signature\";\n this.config.algorithm = config.algorithm || \"sha256\";\n this.config.timestampTolerance = config.timestampTolerance ?? 300;\n }\n\n /**\n * Start the trigger with a handler function\n * @param handler Function to call when webhook requests are received\n */\n async start(handler: TriggerHandler): Promise<void> {\n this.handler = handler;\n }\n\n /**\n * Stop the trigger\n */\n async stop(): Promise<void> {\n this.handler = undefined;\n }\n\n /**\n * Health check - returns true if handler is registered\n */\n async healthCheck(): Promise<boolean> {\n return this.handler !== undefined;\n }\n\n /**\n * Handle an incoming webhook request\n *\n * This method should be called by framework adapters to process webhook requests.\n * It validates the signature, checks for replay attacks, creates a trigger event,\n * and invokes the registered handler.\n *\n * @param request Web standard Request object\n * @returns Web standard Response object\n *\n * @example\n * ```typescript\n * // Hono adapter\n * app.post('/webhook', async (c) => {\n * const response = await trigger.handleRequest(c.req.raw);\n * return response;\n * });\n *\n * // Express adapter\n * app.post('/webhook', async (req, res) => {\n * const request = new Request(`http://localhost${req.url}`, {\n * method: req.method,\n * headers: req.headers,\n * body: JSON.stringify(req.body),\n * });\n * const response = await trigger.handleRequest(request);\n * res.status(response.status).json(await response.json());\n * });\n * ```\n */\n async handleRequest(request: Request): Promise<Response> {\n if (!this.handler) {\n return new Response(JSON.stringify({ error: \"Trigger not started\" }), {\n status: 503,\n headers: { \"Content-Type\": \"application/json\" },\n });\n }\n\n try {\n // Read request body as text for signature verification\n const bodyText = await request.text();\n let data: unknown;\n\n // Parse body based on content type\n const contentType = request.headers.get(\"content-type\") || \"\";\n if (contentType.includes(\"application/json\")) {\n data = JSON.parse(bodyText);\n } else if (contentType.includes(\"application/x-www-form-urlencoded\")) {\n const params = new URLSearchParams(bodyText);\n data = Object.fromEntries(params.entries());\n } else {\n data = bodyText;\n }\n\n // Validate signature\n const isValid = await this.validateSignature(request, bodyText);\n if (!isValid) {\n return new Response(JSON.stringify({ error: \"Invalid signature\" }), {\n status: 401,\n headers: { \"Content-Type\": \"application/json\" },\n });\n }\n\n // Validate timestamp (replay attack prevention)\n if (this.config.timestampHeader) {\n const isTimestampValid = this.validateTimestamp(request);\n if (!isTimestampValid) {\n return new Response(\n JSON.stringify({ error: \"Request timestamp too old or invalid\" }),\n {\n status: 401,\n headers: { \"Content-Type\": \"application/json\" },\n },\n );\n }\n }\n\n // Create trigger event\n const eventId = crypto.randomUUID();\n const event: TriggerEvent = {\n id: eventId,\n type: this.type,\n source: `webhook ${this.config.path}`,\n data,\n metadata: {\n path: this.config.path,\n headers: Object.fromEntries(request.headers.entries()),\n },\n timestamp: new Date(),\n };\n\n // Invoke handler\n await this.handler(event);\n\n // Return success response\n const response: WebhookTriggerResponse = {\n success: true,\n eventId,\n };\n\n return new Response(JSON.stringify(response), {\n status: 200,\n headers: { \"Content-Type\": \"application/json\" },\n });\n } catch (error) {\n return new Response(\n JSON.stringify({\n error: \"Internal server error\",\n message: (error as Error).message,\n }),\n {\n status: 500,\n headers: { \"Content-Type\": \"application/json\" },\n },\n );\n }\n }\n\n /**\n * Validate webhook signature using Web Crypto API\n * Supports multiple signature formats:\n * - GitHub: sha256=<hex>\n * - Stripe: t=timestamp,v1=signature\n * - Generic: <hex>\n * @private\n */\n private async validateSignature(\n request: Request,\n payload: string,\n ): Promise<boolean> {\n const signatureHeader = request.headers.get(this.config.signatureHeader!);\n\n if (!signatureHeader) {\n return false;\n }\n\n try {\n // Parse signature based on format\n let signature: string;\n let timestamp: number | undefined;\n\n // Stripe format: t=timestamp,v1=signature\n if (signatureHeader.includes(\"t=\") && signatureHeader.includes(\"v1=\")) {\n const parts = signatureHeader.split(\",\");\n const tPart = parts.find((p) => p.startsWith(\"t=\"));\n const v1Part = parts.find((p) => p.startsWith(\"v1=\"));\n\n if (!tPart || !v1Part) {\n return false;\n }\n\n timestamp = parseInt(tPart.split(\"=\")[1], 10);\n signature = v1Part.split(\"=\")[1];\n\n // For Stripe, payload includes timestamp\n const signedPayload = `${timestamp}.${payload}`;\n return await this.verifyHmac(signedPayload, signature);\n }\n\n // GitHub format: sha256=<hex> or algorithm prefix\n if (\n this.config.signaturePrefix &&\n signatureHeader.startsWith(this.config.signaturePrefix)\n ) {\n signature = signatureHeader.substring(\n this.config.signaturePrefix.length,\n );\n } else if (signatureHeader.includes(\"=\")) {\n // Generic prefix format\n signature = signatureHeader.split(\"=\")[1];\n } else {\n // No prefix\n signature = signatureHeader;\n }\n\n return await this.verifyHmac(payload, signature);\n } catch (error) {\n console.error(\"Signature validation error:\", error);\n return false;\n }\n }\n\n /**\n * Verify HMAC signature using Web Crypto API\n * @private\n */\n private async verifyHmac(\n payload: string,\n expectedSignature: string,\n ): Promise<boolean> {\n const encoder = new TextEncoder();\n const keyData = encoder.encode(this.config.secret);\n const messageData = encoder.encode(payload);\n\n // Map algorithm to Web Crypto API hash name\n const hashAlgorithm = this.getHashAlgorithm(this.config.algorithm!);\n\n // Import key for HMAC\n const cryptoKey = await crypto.subtle.importKey(\n \"raw\",\n keyData,\n {\n name: \"HMAC\",\n hash: hashAlgorithm,\n },\n false,\n [\"sign\"],\n );\n\n // Generate signature\n const signatureBuffer = await crypto.subtle.sign(\n \"HMAC\",\n cryptoKey,\n messageData,\n );\n\n // Convert to hex string\n const hashArray = Array.from(new Uint8Array(signatureBuffer));\n const computedSignature = hashArray\n .map((b) => b.toString(16).padStart(2, \"0\"))\n .join(\"\");\n\n // Constant-time comparison\n return this.constantTimeCompare(computedSignature, expectedSignature);\n }\n\n /**\n * Map algorithm name to Web Crypto API hash algorithm\n * @private\n */\n private getHashAlgorithm(algorithm: SignatureAlgorithm): string {\n switch (algorithm) {\n case \"sha256\":\n return \"SHA-256\";\n case \"sha1\":\n return \"SHA-1\";\n case \"sha512\":\n return \"SHA-512\";\n default:\n return \"SHA-256\";\n }\n }\n\n /**\n * Constant-time string comparison to prevent timing attacks\n * @private\n */\n private constantTimeCompare(a: string, b: string): boolean {\n if (a.length !== b.length) {\n return false;\n }\n\n let result = 0;\n for (let i = 0; i < a.length; i++) {\n result |= a.charCodeAt(i) ^ b.charCodeAt(i);\n }\n\n return result === 0;\n }\n\n /**\n * Validate timestamp to prevent replay attacks\n * @private\n */\n private validateTimestamp(request: Request): boolean {\n if (!this.config.timestampHeader) {\n return true;\n }\n\n const timestampHeader = request.headers.get(this.config.timestampHeader);\n\n if (!timestampHeader) {\n return false;\n }\n\n try {\n let timestamp: number;\n\n // Stripe format: t=timestamp,v1=signature\n if (timestampHeader.includes(\"t=\")) {\n const tPart = timestampHeader\n .split(\",\")\n .find((p) => p.startsWith(\"t=\"));\n if (!tPart) {\n return false;\n }\n timestamp = parseInt(tPart.split(\"=\")[1], 10);\n } else {\n // Unix timestamp\n timestamp = parseInt(timestampHeader, 10);\n }\n\n if (isNaN(timestamp)) {\n return false;\n }\n\n const now = Math.floor(Date.now() / 1000);\n const age = now - timestamp;\n\n // Check if timestamp is within tolerance\n return age >= 0 && age <= this.config.timestampTolerance!;\n } catch (error) {\n console.error(\"Timestamp validation error:\", error);\n return false;\n }\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stepflowjs/trigger-webhook",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Webhook trigger for Stepflow with signature verification",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@stepflowjs/core": "0.0.1"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"tsup": "^8.5.1",
|
|
23
|
+
"vitest": "^4.0.17"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"typescript": "^5.0.0"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"author": "Stepflow Contributors",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://stepflow-production.up.railway.app",
|
|
33
|
+
"directory": "packages/triggers/webhook"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://stepflow-production.up.railway.app",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://stepflow-production.up.railway.app"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"stepflow",
|
|
41
|
+
"trigger",
|
|
42
|
+
"webhook",
|
|
43
|
+
"signature",
|
|
44
|
+
"verification",
|
|
45
|
+
"workflow",
|
|
46
|
+
"orchestration"
|
|
47
|
+
],
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsup",
|
|
53
|
+
"dev": "tsup --watch",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"test": "vitest",
|
|
56
|
+
"clean": "rm -rf dist"
|
|
57
|
+
}
|
|
58
|
+
}
|