Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions frontend/src/components/WebhookList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
let submitting = $state(false);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
Expand All @@ -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" },
Expand Down Expand Up @@ -187,6 +191,7 @@ function resetForm() {
formName = "";
formAuthType = "hmac-sha256";
formSecret = "";
formHeaderName = "";
formEnabled = true;
formError = null;
}
Expand Down Expand Up @@ -308,7 +313,25 @@ $effect(() => {
<option value="hmac-sha256">HMAC-SHA256</option>
</select>
</div>
{#if formAuthType !== "none"}
{#if formAuthType === "hmac-sha256"}
<div class="space-y-1">
<label for="wh-header" class="text-xs font-medium text-muted-foreground">Header Name</label>
<input
id="wh-header"
type="text"
bind:value={formHeaderName}
placeholder="X-Hub-Signature-256"
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
>
<p class="text-xs text-muted-foreground">Leave blank for default</p>
</div>
{:else}
<div class="space-y-1"></div>
{/if}
</div>

{#if formAuthType !== "none"}
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<label for="wh-secret" class="text-xs font-medium text-muted-foreground">
Secret
Expand All @@ -318,16 +341,13 @@ $effect(() => {
id="wh-secret"
type="password"
bind:value={formSecret}
placeholder={formMode === "edit"
? "unchanged"
: "min 8 characters"}
placeholder={formMode === "edit" ? "unchanged" : "min 8 characters"}
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
>
</div>
{:else}
<div class="space-y-1"></div>
{/if}
</div>
</div>
{/if}

{#if formAuthType === "none"}
<div class="warning-banner">
Expand Down
9 changes: 7 additions & 2 deletions src/app/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
HttpMethod,
RegistryInitDeps,
RouteHandler,
RouteOptions,
RouteRegistry,
RunAgentOptions,
} from "@src/extensions";
Expand Down Expand Up @@ -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);
}
},
};

Expand Down
8 changes: 6 additions & 2 deletions src/extensions/core/webhooks/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
94 changes: 50 additions & 44 deletions src/extensions/core/webhooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>).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<string, string>).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
Expand Down
7 changes: 4 additions & 3 deletions src/extensions/engine/extensionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
HttpMethod,
QueueEventName,
RouteHandler,
RouteOptions,
RunAgentOptions,
StepTypeHandler,
WorkflowDispatchResult,
Expand Down Expand Up @@ -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}`;
Expand All @@ -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);
}
}

Expand Down
1 change: 1 addition & 0 deletions src/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type {
HttpMethod,
QueueEventCallback,
RouteHandler,
RouteOptions,
RouteRegistry,
RunAgentOptions,
SkillScriptContext,
Expand Down
4 changes: 3 additions & 1 deletion src/extensions/internalTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@

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 {
method: HttpMethod;
/** 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. */
Expand Down
17 changes: 15 additions & 2 deletions src/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,20 @@ export type RouteHandler = (ctx: Context) => Response | Promise<Response>;
/** 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;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
};

// -------------------------------------------------------------------------
Expand Down
Loading