@blokjs/trigger-webhook 0.2.0 → 0.6.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/WebhookTrigger.d.ts +100 -117
- package/dist/WebhookTrigger.js +315 -316
- package/dist/index.d.ts +36 -65
- package/dist/index.js +34 -71
- package/dist/verifiers.d.ts +80 -0
- package/dist/verifiers.js +294 -0
- package/package.json +6 -4
- package/src/WebhookTrigger.integration.test.ts +162 -0
- package/src/WebhookTrigger.test.ts +232 -143
- package/src/WebhookTrigger.ts +386 -407
- package/src/index.ts +41 -70
- package/src/verifiers.test.ts +316 -0
- package/src/verifiers.ts +357 -0
package/src/WebhookTrigger.ts
CHANGED
|
@@ -1,480 +1,459 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WebhookTrigger
|
|
2
|
+
* WebhookTrigger — v0.7 PR 4 — Inbound webhook trigger that mounts
|
|
3
|
+
* verified POST routes on the shared Hono app. One route per workflow
|
|
4
|
+
* whose `trigger.webhook` config is registered.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
* - GitHub (push, pull_request, issues, etc.)
|
|
6
|
-
* - Stripe (payment_intent, checkout.session, etc.)
|
|
7
|
-
* - Shopify (orders, products, customers)
|
|
8
|
-
* - Custom webhooks
|
|
6
|
+
* **Authoring surface (built-in provider):**
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
8
|
+
* ```json
|
|
9
|
+
* {
|
|
10
|
+
* "name": "stripe-events",
|
|
11
|
+
* "trigger": {
|
|
12
|
+
* "webhook": {
|
|
13
|
+
* "provider": "stripe",
|
|
14
|
+
* "path": "/webhooks/stripe",
|
|
15
|
+
* "secretEnv": "STRIPE_WEBHOOK_SECRET",
|
|
16
|
+
* "namespace": "stripe",
|
|
17
|
+
* "idempotencyKey": "js/ctx.request.body.id"
|
|
18
|
+
* }
|
|
19
|
+
* },
|
|
20
|
+
* "steps": [
|
|
21
|
+
* { "id": "dispatch", "subworkflow": "js/ctx.request.body.type", "inputs": { "stripeEvent": "js/ctx.request.body" } }
|
|
22
|
+
* ]
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* **Pipeline (per inbound request):**
|
|
27
|
+
*
|
|
28
|
+
* 1. Read raw body — verifiers MUST sign the bytes that crossed
|
|
29
|
+
* the wire, not the JSON-re-stringified body (Stripe / GitHub /
|
|
30
|
+
* Slack all sign raw bytes).
|
|
31
|
+
* 2. Verify the signature via the per-provider strategy
|
|
32
|
+
* (`verifiers.ts`). On failure, return 401 with structured
|
|
33
|
+
* `{ error, reason, message }`.
|
|
34
|
+
* 3. Replay check: if `idempotencyKey` is configured, look up
|
|
35
|
+
* `(workflowName, eventId)` in the idempotency cache (same store
|
|
36
|
+
* as Tier 1 step caching). On hit, return 200 with
|
|
37
|
+
* `{ status: "duplicate", eventId }` and DON'T run the workflow.
|
|
38
|
+
* 4. Events allowlist: if `events: [...]` is configured, skip
|
|
39
|
+
* workflow runs whose event type isn't in the list — return
|
|
40
|
+
* 200 with `{ status: "ignored", eventType }` so the sender
|
|
41
|
+
* doesn't retry.
|
|
42
|
+
* 5. Run the workflow through `TriggerBase.run` so middleware,
|
|
43
|
+
* tracing, retries, concurrency, etc. apply uniformly.
|
|
44
|
+
* 6. Cache the eventId so a retry within the TTL window returns
|
|
45
|
+
* the duplicate response.
|
|
46
|
+
*
|
|
47
|
+
* **Hono integration:** identical to WebSocket and SSE — accepts the
|
|
48
|
+
* shared `Hono<any, any, any>` app and an optional `HttpTriggerLike`
|
|
49
|
+
* exposing `addPreCatchAllHook` so webhook routes mount BEFORE the
|
|
50
|
+
* legacy `/:workflow{.+}` catch-all and win Hono's first-match
|
|
51
|
+
* dispatch.
|
|
52
|
+
*
|
|
53
|
+
* See [additional-triggers-plan.mdx](../../../docs/c/devtools/additional-triggers-plan.mdx#webhook-trigger)
|
|
54
|
+
* for the full v0.7 design.
|
|
15
55
|
*/
|
|
16
56
|
|
|
17
|
-
import crypto from "node:crypto";
|
|
18
|
-
import type { HelperResponse, WebhookTriggerOpts } from "@blokjs/helper";
|
|
19
57
|
import {
|
|
20
58
|
DefaultLogger,
|
|
21
|
-
|
|
22
|
-
type
|
|
23
|
-
NodeMap,
|
|
59
|
+
RunTracker,
|
|
60
|
+
type GlobalOptions as RunnerGlobalOptions,
|
|
24
61
|
TriggerBase,
|
|
25
|
-
|
|
62
|
+
WorkflowRegistry,
|
|
26
63
|
} from "@blokjs/runner";
|
|
27
64
|
import type { Context, RequestContext } from "@blokjs/shared";
|
|
28
65
|
import { type Span, SpanStatusCode, metrics, trace } from "@opentelemetry/api";
|
|
66
|
+
import type { Hono, Context as HonoContext } from "hono";
|
|
29
67
|
import { v4 as uuid } from "uuid";
|
|
30
68
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
/** Raw request body */
|
|
50
|
-
rawBody: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Signature verification result
|
|
55
|
-
*/
|
|
56
|
-
export interface VerificationResult {
|
|
57
|
-
valid: boolean;
|
|
58
|
-
error?: string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Webhook source handlers
|
|
63
|
-
*/
|
|
64
|
-
export interface WebhookSourceHandler {
|
|
65
|
-
/** Extract event type from request */
|
|
66
|
-
getEventType(headers: Record<string, string>, body: unknown): string;
|
|
67
|
-
/** Get signature from request */
|
|
68
|
-
getSignature(headers: Record<string, string>): string | undefined;
|
|
69
|
-
/** Verify signature */
|
|
70
|
-
verifySignature(rawBody: string, signature: string, secret: string): VerificationResult;
|
|
71
|
-
/** Get event ID */
|
|
72
|
-
getEventId(headers: Record<string, string>, body: unknown): string;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Workflow model with webhook trigger configuration
|
|
77
|
-
*/
|
|
78
|
-
interface WebhookWorkflowModel {
|
|
79
|
-
path: string;
|
|
80
|
-
config: {
|
|
81
|
-
name: string;
|
|
82
|
-
version: string;
|
|
83
|
-
trigger?: {
|
|
84
|
-
webhook?: WebhookTriggerOpts;
|
|
85
|
-
[key: string]: unknown;
|
|
86
|
-
};
|
|
87
|
-
[key: string]: unknown;
|
|
69
|
+
import { BUILTIN_VERIFIERS, type Verifier, type VerifyResult, buildCustomVerifier } from "./verifiers";
|
|
70
|
+
|
|
71
|
+
// -----------------------------------------------------------------------------
|
|
72
|
+
// Types
|
|
73
|
+
// -----------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
interface WebhookTriggerConfig {
|
|
76
|
+
provider?: "github" | "stripe" | "slack" | "shopify" | "svix";
|
|
77
|
+
path?: string;
|
|
78
|
+
events?: string[];
|
|
79
|
+
secretEnv?: string;
|
|
80
|
+
signature?: {
|
|
81
|
+
scheme?: "hmac-sha256" | "hmac-sha1" | "hmac-sha512";
|
|
82
|
+
header: string;
|
|
83
|
+
format?: string;
|
|
84
|
+
secretEnv: string;
|
|
85
|
+
tolerance?: number;
|
|
86
|
+
timestampHeader?: string;
|
|
88
87
|
};
|
|
88
|
+
tolerance?: number;
|
|
89
|
+
idempotencyKey?: string;
|
|
90
|
+
namespace?: string;
|
|
91
|
+
middleware?: string[];
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const sourceHandlers: Record<string, WebhookSourceHandler> = {
|
|
95
|
-
github: {
|
|
96
|
-
getEventType: (headers) => headers["x-github-event"] || "unknown",
|
|
97
|
-
getSignature: (headers) => headers["x-hub-signature-256"] || headers["x-hub-signature"],
|
|
98
|
-
verifySignature: (rawBody, signature, secret) => {
|
|
99
|
-
const hmac = crypto.createHmac("sha256", secret);
|
|
100
|
-
const digest = `sha256=${hmac.update(rawBody).digest("hex")}`;
|
|
101
|
-
const sigBuffer = Buffer.from(signature);
|
|
102
|
-
const digestBuffer = Buffer.from(digest);
|
|
103
|
-
// Length check first to avoid timing attack on length
|
|
104
|
-
if (sigBuffer.length !== digestBuffer.length) {
|
|
105
|
-
return { valid: false, error: "Invalid GitHub signature" };
|
|
106
|
-
}
|
|
107
|
-
const valid = crypto.timingSafeEqual(sigBuffer, digestBuffer);
|
|
108
|
-
return { valid, error: valid ? undefined : "Invalid GitHub signature" };
|
|
109
|
-
},
|
|
110
|
-
getEventId: (headers) => headers["x-github-delivery"] || uuid(),
|
|
111
|
-
},
|
|
112
|
-
|
|
113
|
-
stripe: {
|
|
114
|
-
getEventType: (_, body) => (body as { type?: string })?.type || "unknown",
|
|
115
|
-
getSignature: (headers) => headers["stripe-signature"],
|
|
116
|
-
verifySignature: (rawBody, signature, secret) => {
|
|
117
|
-
// Stripe signature format: t=timestamp,v1=signature
|
|
118
|
-
const parts = signature.split(",").reduce(
|
|
119
|
-
(acc, part) => {
|
|
120
|
-
const [key, value] = part.split("=");
|
|
121
|
-
acc[key] = value;
|
|
122
|
-
return acc;
|
|
123
|
-
},
|
|
124
|
-
{} as Record<string, string>,
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
const timestamp = parts.t;
|
|
128
|
-
const expectedSig = parts.v1;
|
|
94
|
+
interface HttpTriggerLike {
|
|
95
|
+
addPreCatchAllHook(cb: () => void | Promise<void>): void;
|
|
96
|
+
}
|
|
129
97
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
98
|
+
const DEFAULT_TOLERANCE_SEC = 300;
|
|
99
|
+
const DEFAULT_REPLAY_TTL_MS = 5 * 60 * 1000; // 5 min — match Stripe / Svix default.
|
|
100
|
+
const REPLAY_NAMESPACE = "__webhook__";
|
|
133
101
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
102
|
+
// -----------------------------------------------------------------------------
|
|
103
|
+
// Trigger class
|
|
104
|
+
// -----------------------------------------------------------------------------
|
|
137
105
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return { valid: false, error: "Invalid Stripe signature" };
|
|
142
|
-
}
|
|
143
|
-
const valid = crypto.timingSafeEqual(sigBuffer, computedBuffer);
|
|
144
|
-
return { valid, error: valid ? undefined : "Invalid Stripe signature" };
|
|
145
|
-
},
|
|
146
|
-
getEventId: (_, body) => (body as { id?: string })?.id || uuid(),
|
|
147
|
-
},
|
|
148
|
-
|
|
149
|
-
shopify: {
|
|
150
|
-
getEventType: (headers) => headers["x-shopify-topic"] || "unknown",
|
|
151
|
-
getSignature: (headers) => headers["x-shopify-hmac-sha256"],
|
|
152
|
-
verifySignature: (rawBody, signature, secret) => {
|
|
153
|
-
const hmac = crypto.createHmac("sha256", secret);
|
|
154
|
-
const digest = hmac.update(rawBody, "utf8").digest("base64");
|
|
155
|
-
const sigBuffer = Buffer.from(signature, "base64");
|
|
156
|
-
const digestBuffer = Buffer.from(digest, "base64");
|
|
157
|
-
if (sigBuffer.length !== digestBuffer.length) {
|
|
158
|
-
return { valid: false, error: "Invalid Shopify signature" };
|
|
159
|
-
}
|
|
160
|
-
const valid = crypto.timingSafeEqual(sigBuffer, digestBuffer);
|
|
161
|
-
return { valid, error: valid ? undefined : "Invalid Shopify signature" };
|
|
162
|
-
},
|
|
163
|
-
getEventId: (headers) => headers["x-shopify-webhook-id"] || uuid(),
|
|
164
|
-
},
|
|
165
|
-
|
|
166
|
-
custom: {
|
|
167
|
-
getEventType: (headers, body) => headers["x-event-type"] || (body as { event?: string })?.event || "custom",
|
|
168
|
-
getSignature: (headers) => headers["x-signature"] || headers["x-webhook-signature"],
|
|
169
|
-
verifySignature: (rawBody, signature, secret) => {
|
|
170
|
-
// Default: HMAC-SHA256
|
|
171
|
-
const hmac = crypto.createHmac("sha256", secret);
|
|
172
|
-
const digest = hmac.update(rawBody).digest("hex");
|
|
173
|
-
const valid = signature === digest || signature === `sha256=${digest}`;
|
|
174
|
-
return { valid, error: valid ? undefined : "Invalid signature" };
|
|
175
|
-
},
|
|
176
|
-
getEventId: (headers, body) => headers["x-event-id"] || (body as { id?: string })?.id || uuid(),
|
|
177
|
-
},
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* WebhookTrigger - Handle webhook events
|
|
182
|
-
*/
|
|
183
|
-
export abstract class WebhookTrigger extends TriggerBase {
|
|
184
|
-
protected nodeMap: GlobalOptions = {} as GlobalOptions;
|
|
106
|
+
export default class WebhookTrigger extends TriggerBase {
|
|
107
|
+
protected nodeMap: RunnerGlobalOptions = {} as RunnerGlobalOptions;
|
|
108
|
+
protected readonly logger = new DefaultLogger();
|
|
185
109
|
protected readonly tracer = trace.getTracer(
|
|
186
110
|
process.env.PROJECT_NAME || "trigger-webhook-workflow",
|
|
187
111
|
process.env.PROJECT_VERSION || "0.0.1",
|
|
188
112
|
);
|
|
189
|
-
protected readonly logger = new DefaultLogger();
|
|
190
|
-
protected webhookWorkflows: WebhookWorkflowModel[] = [];
|
|
191
|
-
|
|
192
|
-
// Subclasses provide these
|
|
193
|
-
protected abstract nodes: Record<string, BlokService<unknown>>;
|
|
194
|
-
protected abstract workflows: Record<string, HelperResponse>;
|
|
195
113
|
|
|
196
|
-
|
|
114
|
+
private readonly meter = metrics.getMeter("blok");
|
|
115
|
+
private readonly counterReceived = this.meter.createCounter("blok_webhook_received_total", {
|
|
116
|
+
description: "Webhook deliveries received (cumulative).",
|
|
117
|
+
unit: "1",
|
|
118
|
+
});
|
|
119
|
+
private readonly counterRejected = this.meter.createCounter("blok_webhook_rejected_total", {
|
|
120
|
+
description: "Webhook deliveries rejected (signature failure, allowlist miss, replay).",
|
|
121
|
+
unit: "1",
|
|
122
|
+
});
|
|
123
|
+
private readonly counterAccepted = this.meter.createCounter("blok_webhook_accepted_total", {
|
|
124
|
+
description: "Webhook deliveries that triggered a workflow run.",
|
|
125
|
+
unit: "1",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// biome-ignore lint/suspicious/noExplicitAny: Hono's generic propagation
|
|
129
|
+
private readonly app: Hono<any, any, any>;
|
|
130
|
+
private readonly httpTrigger: HttpTriggerLike | null;
|
|
131
|
+
|
|
132
|
+
private wired = false;
|
|
133
|
+
|
|
134
|
+
// biome-ignore lint/suspicious/noExplicitAny: matches `app` field's any generic
|
|
135
|
+
constructor(app: Hono<any, any, any>, httpTrigger?: HttpTriggerLike) {
|
|
197
136
|
super();
|
|
198
|
-
this.
|
|
199
|
-
this.
|
|
137
|
+
this.app = app;
|
|
138
|
+
this.httpTrigger = httpTrigger ?? null;
|
|
139
|
+
_setActiveWebhookTrigger(this);
|
|
200
140
|
}
|
|
201
141
|
|
|
202
142
|
/**
|
|
203
|
-
*
|
|
143
|
+
* Inject the runner's GlobalOptions (nodes + workflows). Called by
|
|
144
|
+
* the orchestrator AFTER constructing the trigger but BEFORE
|
|
145
|
+
* `listen()`. Shares HttpTrigger's nodeMap so per-request workflow
|
|
146
|
+
* runs resolve helpers + sub-workflows through the same registry.
|
|
204
147
|
*/
|
|
205
|
-
|
|
206
|
-
this.nodeMap
|
|
207
|
-
const nodeKeys = Object.keys(this.nodes);
|
|
208
|
-
for (const key of nodeKeys) {
|
|
209
|
-
this.nodeMap.nodes.addNode(key, this.nodes[key]);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Load workflows into the workflow map
|
|
215
|
-
*/
|
|
216
|
-
loadWorkflows(): void {
|
|
217
|
-
this.nodeMap.workflows = this.workflows;
|
|
148
|
+
setNodeMap(nodeMap: RunnerGlobalOptions): void {
|
|
149
|
+
this.nodeMap = nodeMap;
|
|
218
150
|
}
|
|
219
151
|
|
|
220
|
-
/**
|
|
221
|
-
* Initialize webhook trigger (call after loading workflows)
|
|
222
|
-
*/
|
|
223
152
|
async listen(): Promise<number> {
|
|
224
153
|
const startTime = this.startCounter();
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (this.webhookWorkflows.length === 0) {
|
|
230
|
-
this.logger.log("No workflows with webhook triggers found");
|
|
231
|
-
} else {
|
|
232
|
-
this.logger.log(`Webhook trigger initialized. ${this.webhookWorkflows.length} workflow(s) registered`);
|
|
154
|
+
if (this.wired) {
|
|
155
|
+
this.logger.log("[blok][webhook] listen() called twice; ignoring");
|
|
156
|
+
return this.endCounter(startTime);
|
|
233
157
|
}
|
|
158
|
+
this.wired = true;
|
|
234
159
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
160
|
+
if (this.httpTrigger) {
|
|
161
|
+
this.httpTrigger.addPreCatchAllHook(() => {
|
|
162
|
+
this.registerRoutesFromRegistry();
|
|
163
|
+
});
|
|
164
|
+
} else {
|
|
165
|
+
this.registerRoutesFromRegistry();
|
|
238
166
|
}
|
|
239
167
|
|
|
240
168
|
return this.endCounter(startTime);
|
|
241
169
|
}
|
|
242
170
|
|
|
243
|
-
/**
|
|
244
|
-
* Stop the webhook trigger
|
|
245
|
-
*/
|
|
246
171
|
async stop(): Promise<void> {
|
|
247
|
-
this.
|
|
248
|
-
|
|
172
|
+
this.wired = false;
|
|
173
|
+
if (_getActiveWebhookTrigger() === this) _setActiveWebhookTrigger(null);
|
|
174
|
+
this.destroyMonitoring();
|
|
175
|
+
this.logger.log("[blok][webhook] stopped");
|
|
249
176
|
}
|
|
250
177
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
this.logger.log(`[HMR] Webhook workflows reloaded. ${this.webhookWorkflows.length} workflow(s) registered`);
|
|
255
|
-
}
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Route registration
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
256
181
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const handler = sourceHandlers[source] || sourceHandlers.custom;
|
|
267
|
-
|
|
268
|
-
// Parse body
|
|
269
|
-
let body: unknown;
|
|
270
|
-
try {
|
|
271
|
-
body = JSON.parse(rawBody);
|
|
272
|
-
} catch {
|
|
273
|
-
body = rawBody;
|
|
182
|
+
private registerRoutesFromRegistry(): void {
|
|
183
|
+
const workflows = this.getWebhookWorkflows();
|
|
184
|
+
if (workflows.length === 0) {
|
|
185
|
+
this.logger.log("[blok][webhook] no workflows with trigger.webhook found");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this.logger.log(`[blok][webhook] registering ${workflows.length} webhook route(s):`);
|
|
189
|
+
for (const entry of workflows) {
|
|
190
|
+
this.registerWebhookRoute(entry);
|
|
274
191
|
}
|
|
192
|
+
}
|
|
275
193
|
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
payload: body,
|
|
282
|
-
headers,
|
|
283
|
-
signature: handler.getSignature(headers),
|
|
284
|
-
timestamp: new Date(),
|
|
285
|
-
rawBody,
|
|
286
|
-
};
|
|
194
|
+
private registerWebhookRoute(entry: { workflowName: string; config: WebhookTriggerConfig }): void {
|
|
195
|
+
const { workflowName, config } = entry;
|
|
196
|
+
const path = config.path ?? (config.provider ? `/webhooks/${config.provider}` : `/webhooks/${workflowName}`);
|
|
197
|
+
const label = config.provider ?? "custom";
|
|
198
|
+
this.logger.log(`[blok][webhook] POST ${path} ← ${workflowName} (${label})`);
|
|
287
199
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
200
|
+
this.app.post(path, (c: HonoContext) => this.handleRequest(c, workflowName, path, config));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async handleRequest(
|
|
204
|
+
c: HonoContext,
|
|
205
|
+
workflowName: string,
|
|
206
|
+
path: string,
|
|
207
|
+
config: WebhookTriggerConfig,
|
|
208
|
+
): Promise<Response> {
|
|
209
|
+
this.counterReceived.add(1, { workflow_name: workflowName });
|
|
210
|
+
|
|
211
|
+
// 1. Capture raw body BEFORE parsing — verifiers sign the wire bytes.
|
|
212
|
+
const rawBody = await c.req.text();
|
|
213
|
+
let parsedBody: unknown = {};
|
|
214
|
+
if (rawBody.length > 0) {
|
|
215
|
+
try {
|
|
216
|
+
parsedBody = JSON.parse(rawBody) as unknown;
|
|
217
|
+
} catch {
|
|
218
|
+
// Non-JSON body — leave parsed as the raw text. Slack
|
|
219
|
+
// challenges & Shopify can post non-JSON occasionally.
|
|
220
|
+
parsedBody = rawBody;
|
|
221
|
+
}
|
|
293
222
|
}
|
|
294
223
|
|
|
295
|
-
const
|
|
224
|
+
const headers = Object.fromEntries(c.req.raw.headers);
|
|
225
|
+
const pathParams = c.req.param() as Record<string, string>;
|
|
226
|
+
const queryParams = Object.fromEntries(new URL(c.req.url).searchParams);
|
|
296
227
|
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
throw new Error(`Signature verification failed: ${verification.error}`);
|
|
303
|
-
}
|
|
304
|
-
} else if (config.secret && !event.signature) {
|
|
305
|
-
this.logger.error("Webhook signature missing but secret is configured");
|
|
306
|
-
throw new Error("Signature missing");
|
|
228
|
+
// 2. Pick the verifier.
|
|
229
|
+
const verifier = this.resolveVerifier(workflowName, config);
|
|
230
|
+
if (!verifier) {
|
|
231
|
+
this.counterRejected.add(1, { workflow_name: workflowName, reason: "no_verifier" });
|
|
232
|
+
return c.json({ error: "Configuration", reason: "no_verifier", message: "No verifier configured" }, 500);
|
|
307
233
|
}
|
|
308
234
|
|
|
309
|
-
|
|
310
|
-
|
|
235
|
+
// 3. Resolve the secret from the env var.
|
|
236
|
+
const secretEnv = config.secretEnv ?? config.signature?.secretEnv;
|
|
237
|
+
const secret = secretEnv ? (process.env[secretEnv] ?? "") : "";
|
|
311
238
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
239
|
+
// 4. Verify.
|
|
240
|
+
const toleranceSec = config.tolerance ?? config.signature?.tolerance ?? DEFAULT_TOLERANCE_SEC;
|
|
241
|
+
const result: VerifyResult = verifier.verify({
|
|
242
|
+
headers,
|
|
243
|
+
rawBody,
|
|
244
|
+
parsedBody,
|
|
245
|
+
secret,
|
|
246
|
+
toleranceSec,
|
|
247
|
+
});
|
|
317
248
|
|
|
318
|
-
|
|
319
|
-
|
|
249
|
+
if (!result.ok) {
|
|
250
|
+
this.counterRejected.add(1, { workflow_name: workflowName, reason: result.reason });
|
|
251
|
+
this.logger.error(
|
|
252
|
+
`[blok][webhook] ${workflowName}: verify failed reason=${result.reason} message="${result.message}"`,
|
|
253
|
+
);
|
|
254
|
+
return c.json({ error: "Unauthorized", reason: result.reason, message: result.message }, 401);
|
|
255
|
+
}
|
|
320
256
|
|
|
321
|
-
|
|
322
|
-
|
|
257
|
+
// 5. Events allowlist — verified-but-out-of-scope returns 200 (no retry).
|
|
258
|
+
if (Array.isArray(config.events) && config.events.length > 0 && !config.events.includes(result.eventType)) {
|
|
259
|
+
this.counterRejected.add(1, { workflow_name: workflowName, reason: "event_not_allowed" });
|
|
260
|
+
return c.json({ status: "ignored", reason: "event_not_allowed", eventType: result.eventType }, 200);
|
|
261
|
+
}
|
|
323
262
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
263
|
+
// 6. Replay protection via the idempotency cache.
|
|
264
|
+
if (config.idempotencyKey && result.eventId) {
|
|
265
|
+
const tracker = RunTracker.getInstance();
|
|
266
|
+
const store = tracker.getStore();
|
|
267
|
+
const cached = store.getIdempotencyCache(REPLAY_NAMESPACE, workflowName, result.eventId);
|
|
268
|
+
if (cached) {
|
|
269
|
+
this.counterRejected.add(1, { workflow_name: workflowName, reason: "replay" });
|
|
270
|
+
return c.json(
|
|
271
|
+
{
|
|
272
|
+
status: "duplicate",
|
|
273
|
+
eventId: result.eventId,
|
|
274
|
+
eventType: result.eventType,
|
|
275
|
+
firstSeenRunId: cached.sourceRunId,
|
|
276
|
+
},
|
|
277
|
+
200,
|
|
278
|
+
);
|
|
330
279
|
}
|
|
331
280
|
}
|
|
332
281
|
|
|
333
|
-
|
|
282
|
+
// 7. Verified + new event — dispatch the workflow.
|
|
283
|
+
this.counterAccepted.add(1, { workflow_name: workflowName });
|
|
284
|
+
const requestId = uuid();
|
|
285
|
+
const dispatchOutcome = await this.dispatchWorkflow({
|
|
286
|
+
workflowName,
|
|
287
|
+
path,
|
|
288
|
+
config,
|
|
289
|
+
requestId,
|
|
290
|
+
headers,
|
|
291
|
+
body: parsedBody,
|
|
292
|
+
rawBody,
|
|
293
|
+
pathParams,
|
|
294
|
+
queryParams,
|
|
295
|
+
eventId: result.eventId,
|
|
296
|
+
eventType: result.eventType,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// 8. Cache event id AFTER successful dispatch so retries on the
|
|
300
|
+
// same delivery are deduped. We cache even on workflow failure
|
|
301
|
+
// — webhook senders should not retry deliveries they've
|
|
302
|
+
// already delivered (the workflow's own retry / DLQ owns that).
|
|
303
|
+
if (config.idempotencyKey && result.eventId) {
|
|
304
|
+
const tracker = RunTracker.getInstance();
|
|
305
|
+
const store = tracker.getStore();
|
|
306
|
+
store.setIdempotencyCache(REPLAY_NAMESPACE, workflowName, result.eventId, {
|
|
307
|
+
data: { eventId: result.eventId, eventType: result.eventType },
|
|
308
|
+
cachedAt: Date.now(),
|
|
309
|
+
expiresAt: Date.now() + DEFAULT_REPLAY_TTL_MS,
|
|
310
|
+
sourceRunId: requestId,
|
|
311
|
+
sourceNodeRunId: requestId,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 9. Shape the response. Workflow's `ctx.response` lands in the
|
|
316
|
+
// body for parity with HTTP triggers.
|
|
317
|
+
return c.json(
|
|
318
|
+
{
|
|
319
|
+
status: "ok",
|
|
320
|
+
eventId: result.eventId,
|
|
321
|
+
eventType: result.eventType,
|
|
322
|
+
runId: requestId,
|
|
323
|
+
response: dispatchOutcome,
|
|
324
|
+
},
|
|
325
|
+
200,
|
|
326
|
+
);
|
|
334
327
|
}
|
|
335
328
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if (!config) continue;
|
|
343
|
-
|
|
344
|
-
// Check source match
|
|
345
|
-
if (config.source !== event.source) continue;
|
|
346
|
-
|
|
347
|
-
// Check event type match
|
|
348
|
-
if (config.events && config.events.length > 0) {
|
|
349
|
-
const matches = config.events.some((pattern) => {
|
|
350
|
-
// Support wildcards (e.g., "push", "pull_request.*")
|
|
351
|
-
if (pattern === "*") return true;
|
|
352
|
-
if (pattern.endsWith(".*")) {
|
|
353
|
-
const prefix = pattern.slice(0, -2);
|
|
354
|
-
return event.eventType.startsWith(prefix);
|
|
355
|
-
}
|
|
356
|
-
return pattern === event.eventType;
|
|
357
|
-
});
|
|
358
|
-
if (!matches) continue;
|
|
329
|
+
private resolveVerifier(workflowName: string, config: WebhookTriggerConfig): Verifier | null {
|
|
330
|
+
if (config.provider) {
|
|
331
|
+
const v = BUILTIN_VERIFIERS[config.provider];
|
|
332
|
+
if (!v) {
|
|
333
|
+
this.logger.error(`[blok][webhook] ${workflowName}: unknown provider "${config.provider}"`);
|
|
334
|
+
return null;
|
|
359
335
|
}
|
|
360
|
-
|
|
361
|
-
|
|
336
|
+
return v;
|
|
337
|
+
}
|
|
338
|
+
if (config.signature) {
|
|
339
|
+
return buildCustomVerifier({
|
|
340
|
+
scheme: config.signature.scheme ?? "hmac-sha256",
|
|
341
|
+
header: config.signature.header,
|
|
342
|
+
format: config.signature.format ?? "{hex}",
|
|
343
|
+
secretEnv: config.signature.secretEnv,
|
|
344
|
+
tolerance: config.signature.tolerance ?? DEFAULT_TOLERANCE_SEC,
|
|
345
|
+
timestampHeader: config.signature.timestampHeader,
|
|
346
|
+
});
|
|
362
347
|
}
|
|
363
|
-
|
|
364
348
|
return null;
|
|
365
349
|
}
|
|
366
350
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
// Create context
|
|
394
|
-
const ctx: Context = this.createContext(undefined, workflow.path, executionId);
|
|
395
|
-
|
|
396
|
-
// Populate request with webhook event
|
|
397
|
-
ctx.request = {
|
|
398
|
-
body: event.payload,
|
|
399
|
-
headers: event.headers,
|
|
400
|
-
query: {},
|
|
401
|
-
params: {
|
|
402
|
-
source: event.source,
|
|
403
|
-
eventType: event.eventType,
|
|
404
|
-
eventId: event.id,
|
|
405
|
-
},
|
|
406
|
-
} as unknown as RequestContext;
|
|
407
|
-
|
|
408
|
-
// Store webhook context in vars
|
|
409
|
-
if (!ctx.vars) ctx.vars = {};
|
|
410
|
-
ctx.vars._webhook_event = {
|
|
411
|
-
id: event.id,
|
|
412
|
-
source: event.source,
|
|
413
|
-
eventType: event.eventType,
|
|
414
|
-
timestamp: event.timestamp.toISOString(),
|
|
415
|
-
hasSignature: String(!!event.signature),
|
|
416
|
-
};
|
|
417
|
-
|
|
418
|
-
ctx.logger.log(`Processing webhook: ${event.source}/${event.eventType} (${event.id})`);
|
|
419
|
-
|
|
420
|
-
// Execute workflow
|
|
421
|
-
const response: TriggerResponse = await this.run(ctx);
|
|
422
|
-
const end = performance.now();
|
|
423
|
-
|
|
424
|
-
// Set span attributes
|
|
425
|
-
span.setAttribute("success", true);
|
|
426
|
-
span.setAttribute("event_id", event.id);
|
|
427
|
-
span.setAttribute("source", event.source);
|
|
428
|
-
span.setAttribute("event_type", event.eventType);
|
|
429
|
-
span.setAttribute("workflow_path", workflow.path);
|
|
430
|
-
span.setAttribute("elapsed_ms", end - start);
|
|
431
|
-
span.setStatus({ code: SpanStatusCode.OK });
|
|
432
|
-
|
|
433
|
-
// Record metrics
|
|
434
|
-
webhookExecutions.add(1, {
|
|
435
|
-
env: process.env.NODE_ENV,
|
|
436
|
-
source: event.source,
|
|
437
|
-
event_type: event.eventType,
|
|
438
|
-
workflow_name: this.configuration.name,
|
|
439
|
-
success: "true",
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
ctx.logger.log(`Webhook processed in ${(end - start).toFixed(2)}ms: ${event.id}`);
|
|
443
|
-
|
|
444
|
-
resolve(response);
|
|
445
|
-
} catch (error) {
|
|
446
|
-
const errorMessage = (error as Error).message;
|
|
447
|
-
|
|
448
|
-
// Set span error
|
|
449
|
-
span.setAttribute("success", false);
|
|
450
|
-
span.recordException(error as Error);
|
|
451
|
-
span.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage });
|
|
452
|
-
|
|
453
|
-
// Record error metrics
|
|
454
|
-
webhookErrors.add(1, {
|
|
455
|
-
env: process.env.NODE_ENV,
|
|
456
|
-
source: event.source,
|
|
457
|
-
event_type: event.eventType,
|
|
458
|
-
workflow_name: this.configuration?.name || "unknown",
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
this.logger.error(`Webhook failed ${event.id}: ${errorMessage}`, (error as Error).stack);
|
|
462
|
-
|
|
463
|
-
throw error;
|
|
464
|
-
} finally {
|
|
465
|
-
span.end();
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// Workflow dispatch
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
private async dispatchWorkflow(opts: {
|
|
356
|
+
workflowName: string;
|
|
357
|
+
path: string;
|
|
358
|
+
config: WebhookTriggerConfig;
|
|
359
|
+
requestId: string;
|
|
360
|
+
headers: Record<string, string>;
|
|
361
|
+
body: unknown;
|
|
362
|
+
rawBody: string;
|
|
363
|
+
pathParams: Record<string, string>;
|
|
364
|
+
queryParams: Record<string, string>;
|
|
365
|
+
eventId: string;
|
|
366
|
+
eventType: string;
|
|
367
|
+
}): Promise<unknown> {
|
|
368
|
+
const { workflowName, requestId, headers, body, rawBody, pathParams, queryParams, eventId, eventType } = opts;
|
|
369
|
+
|
|
370
|
+
return this.tracer.startActiveSpan(`webhook:${workflowName}`, async (span: Span) => {
|
|
371
|
+
try {
|
|
372
|
+
const registry = WorkflowRegistry.getInstance();
|
|
373
|
+
const entry = registry.get(workflowName);
|
|
374
|
+
if (!entry) {
|
|
375
|
+
throw new Error(`[blok][webhook] workflow "${workflowName}" not found in registry`);
|
|
466
376
|
}
|
|
467
|
-
|
|
377
|
+
await this.configuration.init(workflowName, this.nodeMap, entry.workflow);
|
|
378
|
+
|
|
379
|
+
const ctx: Context = this.createContext(undefined, workflowName, requestId);
|
|
380
|
+
ctx.request = {
|
|
381
|
+
body,
|
|
382
|
+
rawBody,
|
|
383
|
+
headers,
|
|
384
|
+
params: pathParams,
|
|
385
|
+
query: queryParams,
|
|
386
|
+
} as unknown as RequestContext;
|
|
387
|
+
|
|
388
|
+
// Stamp webhook metadata onto ctx so polymorphic dispatch
|
|
389
|
+
// can read namespace + event metadata uniformly.
|
|
390
|
+
(ctx as Record<string, unknown>)._webhook = {
|
|
391
|
+
eventId,
|
|
392
|
+
eventType,
|
|
393
|
+
namespace: opts.config.namespace,
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
await this.applyMiddlewareChain(ctx, this.nodeMap);
|
|
397
|
+
await this.run(ctx);
|
|
398
|
+
|
|
399
|
+
span.setAttribute("workflow_name", workflowName);
|
|
400
|
+
span.setAttribute("event_id", eventId);
|
|
401
|
+
span.setAttribute("event_type", eventType);
|
|
402
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
403
|
+
return ctx.response;
|
|
404
|
+
} catch (err) {
|
|
405
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
406
|
+
span.recordException(err as Error);
|
|
407
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: msg });
|
|
408
|
+
this.logger.error(`[blok][webhook] workflow ${workflowName} failed: ${msg}`);
|
|
409
|
+
return { error: msg };
|
|
410
|
+
} finally {
|
|
411
|
+
span.end();
|
|
412
|
+
}
|
|
468
413
|
});
|
|
469
414
|
}
|
|
470
415
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// Introspection
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
getStats(): { workflowsRegistered: number } {
|
|
421
|
+
return { workflowsRegistered: this.getWebhookWorkflows().length };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private getWebhookWorkflows(): Array<{ workflowName: string; config: WebhookTriggerConfig }> {
|
|
425
|
+
const registry = WorkflowRegistry.getInstance();
|
|
426
|
+
const out: Array<{ workflowName: string; config: WebhookTriggerConfig }> = [];
|
|
427
|
+
for (const entry of registry.list()) {
|
|
428
|
+
const wf = entry.workflow as { trigger?: { webhook?: WebhookTriggerConfig } } | undefined;
|
|
429
|
+
const cfg = wf?.trigger?.webhook;
|
|
430
|
+
if (!cfg) continue;
|
|
431
|
+
// Skip configs missing both provider AND signature — they can't
|
|
432
|
+
// verify anything. Authors get a structured error at boot.
|
|
433
|
+
if (!cfg.provider && !cfg.signature) {
|
|
434
|
+
this.logger.error(
|
|
435
|
+
`[blok][webhook] workflow "${entry.name}" has trigger.webhook with neither \`provider\` nor \`signature\` — skipping. Add one to enable signature verification.`,
|
|
436
|
+
);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
out.push({ workflowName: entry.name, config: cfg });
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
476
442
|
}
|
|
477
443
|
}
|
|
478
444
|
|
|
479
|
-
|
|
480
|
-
|
|
445
|
+
// -----------------------------------------------------------------------------
|
|
446
|
+
// Singleton accessor (mirrors WS / SSE)
|
|
447
|
+
// -----------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
let activeTrigger: WebhookTrigger | null = null;
|
|
450
|
+
|
|
451
|
+
export function _setActiveWebhookTrigger(trigger: WebhookTrigger | null): void {
|
|
452
|
+
activeTrigger = trigger;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function _getActiveWebhookTrigger(): WebhookTrigger | null {
|
|
456
|
+
return activeTrigger;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export type { WebhookTriggerConfig };
|