diff --git a/frontend/src/components/WebhookList.svelte b/frontend/src/components/WebhookList.svelte index 9356420..6718a8f 100644 --- a/frontend/src/components/WebhookList.svelte +++ b/frontend/src/components/WebhookList.svelte @@ -37,6 +37,7 @@ let formSlug = $state(""); let formName = $state(""); let formAuthType = $state<"hmac-sha256" | "bearer" | "none">("hmac-sha256"); let formSecret = $state(""); +let formHeaderName = $state(""); let formEnabled = $state(true); let formError = $state(null); let submitting = $state(false); @@ -94,6 +95,7 @@ function openEditForm(webhook: Webhook) { formName = webhook.name; formAuthType = webhook.authType as "hmac-sha256" | "bearer" | "none"; formSecret = ""; + formHeaderName = webhook.headerName || ""; formEnabled = webhook.enabled; formError = null; } @@ -146,6 +148,7 @@ async function submitForm() { name: formName, authType: formAuthType, secret: formAuthType === "none" ? "" : formSecret, + ...(formAuthType === "hmac-sha256" && formHeaderName ? { headerName: formHeaderName } : {}), }), }); const body = await res.json(); @@ -160,6 +163,7 @@ async function submitForm() { enabled: formEnabled, }; if (formSecret) updates.secret = formSecret; + if (formAuthType === "hmac-sha256") updates.headerName = formHeaderName || undefined; const res = await authFetch(`/ext/webhooks/${editingSlug}`, { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -187,6 +191,7 @@ function resetForm() { formName = ""; formAuthType = "hmac-sha256"; formSecret = ""; + formHeaderName = ""; formEnabled = true; formError = null; } @@ -308,7 +313,25 @@ $effect(() => { - {#if formAuthType !== "none"} + {#if formAuthType === "hmac-sha256"} +
+ + +

Leave blank for default

+
+ {:else} +
+ {/if} + + + {#if formAuthType !== "none"} +
- {:else}
- {/if} -
+ + {/if} {#if formAuthType === "none"}
diff --git a/src/app/boot.ts b/src/app/boot.ts index 8203ae4..4633cba 100644 --- a/src/app/boot.ts +++ b/src/app/boot.ts @@ -26,6 +26,7 @@ import type { HttpMethod, RegistryInitDeps, RouteHandler, + RouteOptions, RouteRegistry, RunAgentOptions, } from "@src/extensions"; @@ -317,14 +318,18 @@ export class AppBootstrap { // Route registry that wraps Elysia for extension route wiring. const routeRegistry: RouteRegistry = { - registerRoute(method: HttpMethod, path: string, handler: RouteHandler) { + registerRoute(method: HttpMethod, path: string, handler: RouteHandler, options?: RouteOptions) { const m = method.toLowerCase() as "get" | "post" | "put" | "delete"; const app = elysiaApp as any; if (typeof app[m] !== "function") { log.error(`RouteRegistry: Elysia does not support method "${method}"`); return; } - app[m](path, handler); + if (options) { + app[m](path, handler, options); + } else { + app[m](path, handler); + } }, }; diff --git a/src/extensions/core/webhooks/auth.ts b/src/extensions/core/webhooks/auth.ts index e947dfa..8e1800a 100644 --- a/src/extensions/core/webhooks/auth.ts +++ b/src/extensions/core/webhooks/auth.ts @@ -44,9 +44,13 @@ export async function verifyAuth( ["sign"], ); const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(rawBody)); - const expectedHex = `sha256=${Array.from(new Uint8Array(signature)) + const computedHex = Array.from(new Uint8Array(signature)) .map((b) => b.toString(16).padStart(2, "0")) - .join("")}`; + .join(""); + + // Support both formats: raw hex ("abcdef...") and prefixed ("sha256=abcdef...") + const hasPrefix = headerValue.startsWith("sha256="); + const expectedHex = hasPrefix ? `sha256=${computedHex}` : computedHex; const expectedBytes = Buffer.from(expectedHex); const actualBytes = Buffer.from(headerValue); diff --git a/src/extensions/core/webhooks/index.ts b/src/extensions/core/webhooks/index.ts index fce84f1..7bcc0d4 100644 --- a/src/extensions/core/webhooks/index.ts +++ b/src/extensions/core/webhooks/index.ts @@ -90,56 +90,62 @@ export function createExtension(): Extension { // --------------------------------------------------------------- // Receiver route: POST /ext/webhooks/receive/:slug + // Body parsing is disabled so we receive the raw bytes for HMAC verification. // --------------------------------------------------------------- - ctx.routes.register("POST", "/receive/:slug", async (reqCtx) => { - const slug = (reqCtx.params as Record).slug; - if (!slug) return Response.json({ error: "Missing slug" }, { status: 400 }); - - const registration = findWebhook(slug); - if (!registration) return Response.json({ error: "Webhook not found" }, { status: 404 }); - if (!registration.enabled) return Response.json({ error: "Webhook is disabled" }, { status: 403 }); - - // Deny unauthenticated webhooks in non-development environments - if (registration.authType === "none" && !IS_DEV) { - logger.warn(`Blocked unauthenticated webhook "${slug}" - authType "none" is only allowed in development`); - return Response.json( - { error: "Unauthenticated webhooks are only available in development mode" }, - { status: 403 }, - ); - } - - // Read raw body for HMAC verification - const rawBody = typeof reqCtx.body === "string" ? reqCtx.body : JSON.stringify(reqCtx.body ?? ""); + ctx.routes.register( + "POST", + "/receive/:slug", + async (reqCtx) => { + const slug = (reqCtx.params as Record).slug; + if (!slug) return Response.json({ error: "Missing slug" }, { status: 400 }); + + const registration = findWebhook(slug); + if (!registration) return Response.json({ error: "Webhook not found" }, { status: 404 }); + if (!registration.enabled) return Response.json({ error: "Webhook is disabled" }, { status: 403 }); + + // Deny unauthenticated webhooks in non-development environments + if (registration.authType === "none" && !IS_DEV) { + logger.warn(`Blocked unauthenticated webhook "${slug}" - authType "none" is only allowed in development`); + return Response.json( + { error: "Unauthenticated webhooks are only available in development mode" }, + { status: 403 }, + ); + } - if (rawBody.length > maxPayloadSize) { - return Response.json({ error: "Payload too large" }, { status: 413 }); - } + // Read raw body directly from request (body parsing is disabled) + const rawBody = await reqCtx.request.text(); - // Verify authentication - const headerValue = reqCtx.headers[registration.headerName.toLowerCase()] ?? null; - const authentic = await verifyAuth(registration, headerValue, rawBody); - if (!authentic) { - logger.warn(`Auth failed for webhook "${slug}"`); - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } + if (rawBody.length > maxPayloadSize) { + return Response.json({ error: "Payload too large" }, { status: 413 }); + } - // Parse payload - let payload: unknown; - try { - payload = typeof reqCtx.body === "string" ? JSON.parse(reqCtx.body) : reqCtx.body; - } catch { - payload = rawBody; - } + // Verify authentication + const headerValue = reqCtx.headers[registration.headerName.toLowerCase()] ?? null; + const authentic = await verifyAuth(registration, headerValue, rawBody); + if (!authentic) { + logger.warn(`Auth failed for webhook "${slug}"`); + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } - // Emit domain event for downstream consumers (e.g. workflow engine) - ctx.events.emit({ - type: "webhook:received", - context: { source: "webhooks", id: slug, slug, payload }, - }); + // Parse payload + let payload: unknown; + try { + payload = JSON.parse(rawBody); + } catch { + payload = rawBody; + } - logger.info(`Webhook "${slug}" received -> event emitted`); - return Response.json({ ok: true }, { status: 202 }); - }); + // Emit domain event for downstream consumers (e.g. workflow engine) + ctx.events.emit({ + type: "webhook:received", + context: { source: "webhooks", id: slug, slug, payload }, + }); + + logger.info(`Webhook "${slug}" received -> event emitted`); + return Response.json({ ok: true }, { status: 202 }); + }, + { parse: "none" }, + ); // --------------------------------------------------------------- // CRUD routes for webhook registrations diff --git a/src/extensions/engine/extensionContext.ts b/src/extensions/engine/extensionContext.ts index b557c20..3058fa1 100644 --- a/src/extensions/engine/extensionContext.ts +++ b/src/extensions/engine/extensionContext.ts @@ -33,6 +33,7 @@ import type { HttpMethod, QueueEventName, RouteHandler, + RouteOptions, RunAgentOptions, StepTypeHandler, WorkflowDispatchResult, @@ -256,7 +257,7 @@ export function createExtensionContext(deps: ExtensionContextDeps): { * @param handler The route handler function * @throws If the route path is already registered */ - function registerRoute(method: HttpMethod, path: string, handler: RouteHandler): void { + function registerRoute(method: HttpMethod, path: string, handler: RouteHandler, options?: RouteOptions): void { const cleanPath = path.replace(/^\/+/, ""); const fullPath = `/ext/${extensionName}/${cleanPath}`; const routeKey = `${method}:${fullPath}`; @@ -268,12 +269,12 @@ export function createExtensionContext(deps: ExtensionContextDeps): { } routeKeySet.add(routeKey); - routes.push({ method, fullPath, handler }); + routes.push({ method, fullPath, handler, options }); logger.debug(`Extension "${extensionName}" registered route ${method} ${fullPath}`); // Wire directly into the HTTP server if (routeRegistry) { - routeRegistry.registerRoute(method, fullPath, handler); + routeRegistry.registerRoute(method, fullPath, handler, options); } } diff --git a/src/extensions/index.ts b/src/extensions/index.ts index f5ff1c5..ed643e2 100644 --- a/src/extensions/index.ts +++ b/src/extensions/index.ts @@ -18,6 +18,7 @@ export type { HttpMethod, QueueEventCallback, RouteHandler, + RouteOptions, RouteRegistry, RunAgentOptions, SkillScriptContext, diff --git a/src/extensions/internalTypes.ts b/src/extensions/internalTypes.ts index 7e495fc..86588f8 100644 --- a/src/extensions/internalTypes.ts +++ b/src/extensions/internalTypes.ts @@ -5,7 +5,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { ManagedQueuePort } from "@src/queue"; -import type { Extension, HttpMethod, RouteHandler, StepTypeHandler } from "./types"; +import type { Extension, HttpMethod, RouteHandler, RouteOptions, StepTypeHandler } from "./types"; /** A route registered by an extension (includes the fully-qualified path). */ export interface RegisteredRoute { @@ -13,6 +13,8 @@ export interface RegisteredRoute { /** Full path including the /ext/{extensionName}/ prefix. */ fullPath: string; handler: RouteHandler; + /** Optional route-level configuration (e.g. parse options). */ + options?: RouteOptions; } /** A custom workflow step type registered by an extension. */ diff --git a/src/extensions/types.ts b/src/extensions/types.ts index 375c584..2029738 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -222,7 +222,20 @@ export type RouteHandler = (ctx: Context) => Response | Promise; /** Minimal route registration surface for wiring extension routes into the HTTP server. */ export interface RouteRegistry { /** Register a single HTTP route handler for an extension. */ - registerRoute(method: HttpMethod, path: string, handler: RouteHandler): void; + registerRoute(method: HttpMethod, path: string, handler: RouteHandler, options?: RouteOptions): void; +} + +/** + * Options passed through to the underlying HTTP server route definition. + * Maps to Elysia route-level configuration (e.g. skipping body parsing). + */ +export interface RouteOptions { + /** + * Controls body parsing behavior. + * Set to `"none"` to skip body parsing entirely (raw request body). + * Can also be a specific content type like `"json"`, `"text"`, `"formdata"`, `"urlencoded"`. + */ + parse?: "none" | "json" | "text" | "formdata" | "urlencoded" | string; } // --------------------------------------------------------------------------- @@ -468,7 +481,7 @@ export interface ExtensionContext { /** HTTP route registration (path auto-prefixed with `/ext/{extensionName}/`). */ readonly routes: { - register(method: HttpMethod, path: string, handler: RouteHandler): void; + register(method: HttpMethod, path: string, handler: RouteHandler, options?: RouteOptions): void; }; // -------------------------------------------------------------------------