diff --git a/docs/automation/webhook.md b/docs/automation/webhook.md index 81565bd41..2714feb13 100644 --- a/docs/automation/webhook.md +++ b/docs/automation/webhook.md @@ -101,6 +101,116 @@ Mapping options (summary): - `moltbot webhooks gmail setup` writes `hooks.gmail` config for `moltbot webhooks gmail run`. See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow. +## Transform Functions + +When templates aren't flexible enough, use a transform function to process webhook +payloads with code. + +### Basic Example + +```yaml +hooks: + enabled: true + token: "secret" + transformsDir: ./hooks # relative to config file + mappings: + - id: github + match: + path: github + action: agent + transform: + module: github.js # resolves to ./hooks/github.js + export: handleWebhook # optional, defaults to "default" or "transform" +``` + +The transform function receives a context object: + +```typescript +type HookMappingContext = { + payload: Record; // Parsed JSON body + headers: Record; // Lowercase header names + url: URL; // Parsed request URL + path: string; // Subpath after /hooks/ (e.g., "github") +}; +``` + +```javascript +// hooks/github.js +export function handleWebhook(ctx) { + const event = ctx.headers["x-github-event"]; + + // Return null to skip this webhook entirely (no agent run) + if (event === "ping") return null; + + // Return fields to override the mapping defaults + return { + message: `GitHub ${event}: ${ctx.payload.action} on ${ctx.payload.repository?.full_name}`, + name: "GitHub", + sessionKey: `github:${ctx.payload.repository?.id}`, + }; +} +``` + +### Return Values + +Return an object with fields to override, or `null` to skip: + +```typescript +// For action: "agent" (default) +return { + message: string; // Required if not set in mapping + name?: string; // Display name for the hook + sessionKey?: string; // Session identifier + wakeMode?: "now" | "next-heartbeat"; + deliver?: boolean; // Send response to chat + channel?: string; // Target channel + to?: string; // Recipient + model?: string; // Model override + thinking?: string; // Thinking level + timeoutSeconds?: number; +}; + +// For action: "wake" +return { + text: string; // Required + mode?: "now" | "next-heartbeat"; +}; + +// Skip this webhook (no action taken, returns 204) +return null; +``` + +### Async Transforms + +Transforms can be async: + +```javascript +export default async function(ctx) { + const extra = await fetchAdditionalContext(ctx.payload.id); + return { + message: `Event: ${ctx.payload.type}\nContext: ${extra}`, + }; +} +``` + +### TypeScript + +JavaScript (`.js` / `.mjs`) transforms work out of the box with no extra setup. + +TypeScript transforms require a loader at runtime: + +```bash +# Run gateway with tsx +npx tsx node_modules/.bin/moltbot gateway start + +# Or use bun +bun run moltbot gateway start + +# Or precompile to .js +tsc hooks/*.ts --outDir hooks-dist +# then reference hooks-dist/github.js in config +``` + ## Responses - `200` for `/hooks/wake` @@ -145,6 +255,91 @@ curl -X POST http://127.0.0.1:18789/hooks/gmail \ -d '{"source":"gmail","messages":[{"from":"Ada","subject":"Hello","snippet":"Hi"}]}' ``` +## Custom Authentication (verifyAuth) + +External webhooks (GitHub, Stripe, Linear, etc.) often use their own authentication +schemes (e.g., HMAC signatures). Instead of using the standard bearer token, you can +export a `verifyAuth` function from your transform module to handle custom auth. + +When a mapping's transform module exports `verifyAuth`, it runs **before** the standard +token check. If it returns `true`, the request is authorized; if `false`, a 401 is returned. + +### Example: GitHub Webhook Signature Verification + +The `verifyAuth` function receives a context with the raw body for signature verification: + +```typescript +type HookVerifyAuthContext = { + headers: Record; // Lowercase header names + url: URL; // Parsed request URL + path: string; // Subpath after /hooks/ + rawBody: Buffer; // Raw request body for signature verification +}; +``` + +```javascript +// hooks/github-transform.js +import { createHmac, timingSafeEqual } from "crypto"; + +// Runs BEFORE token auth - return true to allow, false to reject +export function verifyAuth(ctx) { + const signature = ctx.headers["x-hub-signature-256"]; + if (!signature) return false; + + const secret = process.env.GITHUB_WEBHOOK_SECRET; + const expected = "sha256=" + createHmac("sha256", secret) + .update(ctx.rawBody) + .digest("hex"); + + try { + return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); + } catch { + return false; + } +} + +// Transform the payload (runs after auth passes) +export default function transform(ctx) { + const event = ctx.headers["x-github-event"]; + + // Format based on event type + if (event === "push") { + return { message: `Push to ${ctx.payload.repository?.full_name}: ${ctx.payload.head_commit?.message}` }; + } + if (event === "pull_request") { + return { message: `PR ${ctx.payload.action}: ${ctx.payload.pull_request?.title}` }; + } + + return { message: `GitHub ${event}: ${JSON.stringify(ctx.payload).slice(0, 200)}` }; +} +``` + +Config: +```yaml +hooks: + enabled: true + token: "regular-token" # Still needed for non-custom-auth hooks + mappings: + - id: github + match: + path: github + action: agent + name: GitHub + transform: + module: github-transform.js +``` + +### Async verifyAuth + +`verifyAuth` can be async if needed: + +```javascript +export async function verifyAuth(ctx) { + // async validation (e.g., checking against external service) + return await validateSignature(ctx.headers, ctx.rawBody); +} +``` + ## Security - Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy. diff --git a/src/gateway/hooks-mapping.test.ts b/src/gateway/hooks-mapping.test.ts index 8900ffd07..1c807d343 100644 --- a/src/gateway/hooks-mapping.test.ts +++ b/src/gateway/hooks-mapping.test.ts @@ -3,7 +3,24 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { applyHookMappings, resolveHookMappings } from "./hooks-mapping.js"; +import { + applyMapping, + findMapping, + type HookMappingContext, + type HookMappingResult, + loadVerifyAuth, + resolveHookMappings, +} from "./hooks-mapping.js"; + +/** Test helper: find first matching mapping and apply it. */ +async function applyFirstMatch( + mappings: ReturnType, + ctx: HookMappingContext, +): Promise { + const matched = findMapping(mappings, ctx); + if (!matched) return null; + return applyMapping(matched, ctx); +} const baseUrl = new URL("http://127.0.0.1:18789/hooks/gmail"); @@ -25,7 +42,7 @@ describe("hooks mapping", () => { }, ], }); - const result = await applyHookMappings(mappings, { + const result = await applyFirstMatch(mappings, { payload: { messages: [{ subject: "Hello" }] }, headers: {}, url: baseUrl, @@ -50,7 +67,7 @@ describe("hooks mapping", () => { }, ], }); - const result = await applyHookMappings(mappings, { + const result = await applyFirstMatch(mappings, { payload: { messages: [{ subject: "Hello" }] }, headers: {}, url: baseUrl, @@ -82,7 +99,7 @@ describe("hooks mapping", () => { ], }); - const result = await applyHookMappings(mappings, { + const result = await applyFirstMatch(mappings, { payload: { name: "Ada" }, headers: {}, url: new URL("http://127.0.0.1:18789/hooks/custom"), @@ -114,7 +131,7 @@ describe("hooks mapping", () => { ], }); - const result = await applyHookMappings(mappings, { + const result = await applyFirstMatch(mappings, { payload: {}, headers: {}, url: new URL("http://127.0.0.1:18789/hooks/skip"), @@ -140,7 +157,7 @@ describe("hooks mapping", () => { }, ], }); - const result = await applyHookMappings(mappings, { + const result = await applyFirstMatch(mappings, { payload: { messages: [{ subject: "Hello" }] }, headers: {}, url: baseUrl, @@ -157,7 +174,7 @@ describe("hooks mapping", () => { const mappings = resolveHookMappings({ mappings: [{ match: { path: "noop" }, action: "agent" }], }); - const result = await applyHookMappings(mappings, { + const result = await applyFirstMatch(mappings, { payload: {}, headers: {}, url: new URL("http://127.0.0.1:18789/hooks/noop"), @@ -166,3 +183,73 @@ describe("hooks mapping", () => { expect(result?.ok).toBe(false); }); }); + +describe("loadVerifyAuth", () => { + it("returns undefined when transform has no verifyAuth export", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-hooks-noauth-")); + const modPath = path.join(dir, "transform.mjs"); + fs.writeFileSync(modPath, "export default () => ({ message: 'test' });"); + + const mappings = resolveHookMappings({ + transformsDir: dir, + mappings: [ + { + match: { path: "github" }, + action: "agent", + transform: { module: "transform.mjs" }, + }, + ], + }); + + const result = await loadVerifyAuth(mappings[0]!.transform!); + expect(result).toBeUndefined(); + }); + + it("returns verifyAuth function when exported", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-hooks-auth-")); + const modPath = path.join(dir, "transform.mjs"); + fs.writeFileSync( + modPath, + ` + export function verifyAuth(ctx) { + return ctx.headers["x-secret"] === "valid"; + } + export default () => ({ message: 'test' }); + `, + ); + + const mappings = resolveHookMappings({ + transformsDir: dir, + mappings: [ + { + match: { path: "github" }, + action: "agent", + transform: { module: "transform.mjs" }, + }, + ], + }); + + const result = await loadVerifyAuth(mappings[0]!.transform!); + expect(result).toBeDefined(); + expect(typeof result).toBe("function"); + + // Test the verifyAuth function works + if (result) { + const valid = await result({ + headers: { "x-secret": "valid" }, + url: new URL("http://localhost/hooks/github"), + path: "github", + rawBody: Buffer.from("{}"), + }); + expect(valid).toBe(true); + + const invalid = await result({ + headers: { "x-secret": "wrong" }, + url: new URL("http://localhost/hooks/github"), + path: "github", + rawBody: Buffer.from("{}"), + }); + expect(invalid).toBe(false); + } + }); +}); diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 2ebf9b136..63aafc8c0 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -77,7 +77,30 @@ const hookPresetMappings: Record = { ], }; -const transformCache = new Map(); +/** + * Context for custom auth verification in hook transforms. + * Similar to HookMappingContext but includes the raw body buffer + * for signature verification (e.g., GitHub HMAC signatures). + */ +export type HookVerifyAuthContext = { + headers: Record; + url: URL; + path: string; + rawBody: Buffer; +}; + +/** + * Custom auth verification function exported by transform modules. + * Return true to allow the request, false to reject with 401. + */ +export type HookVerifyAuthFn = (ctx: HookVerifyAuthContext) => boolean | Promise; + +type CachedTransform = { + transform: HookTransformFn; + verifyAuth?: HookVerifyAuthFn; +}; + +const transformCache = new Map(); type HookTransformResult = Partial<{ kind: HookAction["kind"]; @@ -129,32 +152,33 @@ export function resolveHookMappings(hooks?: HooksConfig): HookMappingResolved[] return mappings.map((mapping, index) => normalizeHookMapping(mapping, index, transformsDir)); } -export async function applyHookMappings( +export function findMapping( mappings: HookMappingResolved[], ctx: HookMappingContext, -): Promise { - if (mappings.length === 0) return null; - for (const mapping of mappings) { - if (!mappingMatches(mapping, ctx)) continue; +): HookMappingResolved | undefined { + return mappings.find((m) => mappingMatches(m, ctx)); +} - const base = buildActionFromMapping(mapping, ctx); - if (!base.ok) return base; +export async function applyMapping( + mapping: HookMappingResolved, + ctx: HookMappingContext, +): Promise { + const base = buildActionFromMapping(mapping, ctx); + if (!base.ok) return base; - let override: HookTransformResult = null; - if (mapping.transform) { - const transform = await loadTransform(mapping.transform); - override = await transform(ctx); - if (override === null) { - return { ok: true, action: null, skipped: true }; - } + let override: HookTransformResult = null; + if (mapping.transform) { + const transform = await loadTransform(mapping.transform); + override = await transform(ctx); + if (override === null) { + return { ok: true, action: null, skipped: true }; } - - if (!base.action) return { ok: true, action: null, skipped: true }; - const merged = mergeAction(base.action, override, mapping.action); - if (!merged.ok) return merged; - return merged; } - return null; + + if (!base.action) return { ok: true, action: null, skipped: true }; + const merged = mergeAction(base.action, override, mapping.action); + if (!merged.ok) return merged; + return merged; } function normalizeHookMapping( @@ -293,14 +317,42 @@ function validateAction(action: HookAction): HookMappingResult { return { ok: true, action }; } -async function loadTransform(transform: HookMappingTransformResolved): Promise { +async function loadTransformModule( + transform: HookMappingTransformResolved, +): Promise { const cached = transformCache.get(transform.modulePath); if (cached) return cached; const url = pathToFileURL(transform.modulePath).href; const mod = (await import(url)) as Record; - const fn = resolveTransformFn(mod, transform.exportName); - transformCache.set(transform.modulePath, fn); - return fn; + const transformFn = resolveTransformFn(mod, transform.exportName); + const verifyAuth = resolveVerifyFn(mod); + const result: CachedTransform = { transform: transformFn, verifyAuth }; + transformCache.set(transform.modulePath, result); + return result; +} + +async function loadTransform(transform: HookMappingTransformResolved): Promise { + const cached = await loadTransformModule(transform); + return cached.transform; +} + +/** + * Load a transform module and return its verifyAuth export, if any. + */ +export async function loadVerifyAuth( + transform: HookMappingTransformResolved, +): Promise { + const mod = await loadTransformModule(transform); + return mod.verifyAuth; +} + +function resolveVerifyFn(mod: Record): HookVerifyAuthFn | undefined { + const candidate = mod.verifyAuth; + if (candidate == null) return undefined; + if (typeof candidate !== "function") { + throw new Error("hook verifyAuth export must be a function"); + } + return candidate as HookVerifyAuthFn; } function resolveTransformFn(mod: Record, exportName?: string): HookTransformFn { diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index a943f00ab..7239bb63a 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -1,4 +1,5 @@ import type { IncomingMessage } from "node:http"; +import { Readable } from "node:stream"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import type { MoltbotConfig } from "../config/config.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; @@ -8,7 +9,10 @@ import { extractHookToken, normalizeAgentPayload, normalizeWakePayload, + parseJsonBody, + readRawBody, resolveHooksConfig, + authenticateHook, } from "./hooks.js"; describe("gateway hooks helpers", () => { @@ -150,3 +154,92 @@ const createMSTeamsPlugin = (params: { aliases?: string[] }): ChannelPlugin => ( resolveAccount: () => ({}), }, }); + +describe("readRawBody and parseJsonBody", () => { + test("readRawBody reads stream into buffer", async () => { + const req = Readable.from([ + Buffer.from('{"foo":'), + Buffer.from('"bar"}'), + ]) as unknown as IncomingMessage; + const result = await readRawBody(req, 1024); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.toString()).toBe('{"foo":"bar"}'); + } + }); + + test("parseJsonBody parses valid JSON", () => { + const result = parseJsonBody(Buffer.from('{"hello":"world"}')); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toEqual({ hello: "world" }); + } + }); + + test("parseJsonBody returns empty object for empty buffer", () => { + const result = parseJsonBody(Buffer.from("")); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toEqual({}); + } + }); + + test("parseJsonBody rejects invalid JSON", () => { + const result = parseJsonBody(Buffer.from("not json")); + expect(result.ok).toBe(false); + }); +}); + +describe("authenticateHook", () => { + function createMockRequest(headers: Record = {}): IncomingMessage { + return { headers } as unknown as IncomingMessage; + } + + test("valid token succeeds", async () => { + const result = await authenticateHook({ + req: createMockRequest({ authorization: "Bearer secret123" }), + url: new URL("http://localhost/hooks/test"), + subPath: "test", + headers: { authorization: "Bearer secret123" }, + rawBody: Buffer.from("{}"), + transform: undefined, + expectedToken: "secret123", + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.tokenFromQuery).toBe(false); + } + }); + + test("invalid token fails", async () => { + const result = await authenticateHook({ + req: createMockRequest({ authorization: "Bearer wrong" }), + url: new URL("http://localhost/hooks/test"), + subPath: "test", + headers: { authorization: "Bearer wrong" }, + rawBody: Buffer.from("{}"), + transform: undefined, + expectedToken: "secret123", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(401); + } + }); + + test("query token sets tokenFromQuery", async () => { + const result = await authenticateHook({ + req: createMockRequest({}), + url: new URL("http://localhost/hooks/test?token=secret123"), + subPath: "test", + headers: {}, + rawBody: Buffer.from("{}"), + transform: undefined, + expectedToken: "secret123", + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.tokenFromQuery).toBe(true); + } + }); +}); diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index 1fc6d52f4..39211abb1 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -4,7 +4,13 @@ import { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; import type { MoltbotConfig } from "../config/config.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; -import { type HookMappingResolved, resolveHookMappings } from "./hooks-mapping.js"; +import { + type HookMappingResolved, + type HookMappingTransformResolved, + type HookVerifyAuthFn, + loadVerifyAuth, + resolveHookMappings, +} from "./hooks-mapping.js"; const DEFAULT_HOOKS_PATH = "/hooks"; const DEFAULT_HOOKS_MAX_BODY_BYTES = 256 * 1024; @@ -61,10 +67,10 @@ export function extractHookToken(req: IncomingMessage, url: URL): HookTokenResul return { token: undefined, fromQuery: false }; } -export async function readJsonBody( +export async function readRawBody( req: IncomingMessage, maxBytes: number, -): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> { +): Promise<{ ok: true; value: Buffer } | { ok: false; error: string }> { return await new Promise((resolve) => { let done = false; let total = 0; @@ -83,17 +89,7 @@ export async function readJsonBody( req.on("end", () => { if (done) return; done = true; - const raw = Buffer.concat(chunks).toString("utf-8").trim(); - if (!raw) { - resolve({ ok: true, value: {} }); - return; - } - try { - const parsed = JSON.parse(raw) as unknown; - resolve({ ok: true, value: parsed }); - } catch (err) { - resolve({ ok: false, error: String(err) }); - } + resolve({ ok: true, value: Buffer.concat(chunks) }); }); req.on("error", (err) => { if (done) return; @@ -103,6 +99,97 @@ export async function readJsonBody( }); } +export function parseJsonBody( + raw: Buffer, +): { ok: true; value: unknown } | { ok: false; error: string } { + const str = raw.toString("utf-8").trim(); + if (!str) { + return { ok: true, value: {} }; + } + try { + const parsed = JSON.parse(str) as unknown; + return { ok: true, value: parsed }; + } catch (err) { + return { ok: false, error: String(err) }; + } +} + +export async function readJsonBody( + req: IncomingMessage, + maxBytes: number, +): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> { + const raw = await readRawBody(req, maxBytes); + if (!raw.ok) return raw; + return parseJsonBody(raw.value); +} + +export type AuthenticateHookContext = { + req: IncomingMessage; + url: URL; + subPath: string; + headers: Record; + rawBody: Buffer; + transform: HookMappingTransformResolved | undefined; + expectedToken: string; +}; + +export type AuthenticateHookResult = + | { ok: true; tokenFromQuery: boolean } + | { ok: false; status: number; error: string }; + +/** + * Authenticate a webhook request. + * + * If the mapping has a verifyAuth export, use custom auth. + * Otherwise, fall back to standard token auth. + */ +export async function authenticateHook( + ctx: AuthenticateHookContext, +): Promise { + if (ctx.transform) { + const result = await verifyFromMapping(ctx.transform, ctx); + if (result) return result; + } + return verifyFromToken(ctx.req, ctx.url, ctx.expectedToken); +} + +async function verifyFromMapping( + transform: HookMappingTransformResolved, + ctx: Pick, +): Promise { + let verifyAuth: HookVerifyAuthFn | undefined; + try { + verifyAuth = await loadVerifyAuth(transform); + } catch (err) { + return { ok: false, status: 500, error: `Failed to load verifyAuth: ${String(err)}` }; + } + if (!verifyAuth) return undefined; + try { + const ok = await verifyAuth({ + headers: ctx.headers, + url: ctx.url, + path: ctx.subPath, + rawBody: ctx.rawBody, + }); + if (!ok) return { ok: false, status: 401, error: "Unauthorized" }; + return { ok: true, tokenFromQuery: false }; + } catch (err) { + return { ok: false, status: 401, error: `verifyAuth error: ${String(err)}` }; + } +} + +function verifyFromToken( + req: IncomingMessage, + url: URL, + expectedToken: string, +): AuthenticateHookResult { + const { token, fromQuery } = extractHookToken(req, url); + if (!token || token !== expectedToken) { + return { ok: false, status: 401, error: "Unauthorized" }; + } + return { ok: true, tokenFromQuery: fromQuery }; +} + export function normalizeHookHeaders(req: IncomingMessage) { const headers: Record = {}; for (const [key, value] of Object.entries(req.headers)) { diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index f08dc811c..4829118fc 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -15,18 +15,19 @@ import { handleSlackHttpRequest } from "../slack/http/index.js"; import { resolveAgentAvatar } from "../agents/identity-avatar.js"; import { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js"; import { - extractHookToken, + authenticateHook, getHookChannelError, type HookMessageChannel, type HooksConfigResolved, normalizeAgentPayload, normalizeHookHeaders, normalizeWakePayload, - readJsonBody, + parseJsonBody, + readRawBody, resolveHookChannel, resolveHookDeliver, } from "./hooks.js"; -import { applyHookMappings } from "./hooks-mapping.js"; +import { applyMapping, findMapping } from "./hooks-mapping.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js"; @@ -76,21 +77,6 @@ export function createHooksRequestHandler( return false; } - const { token, fromQuery } = extractHookToken(req, url); - if (!token || token !== hooksConfig.token) { - res.statusCode = 401; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Unauthorized"); - return true; - } - if (fromQuery) { - logHooks.warn( - "Hook token provided via query parameter is deprecated for security reasons. " + - "Tokens in URLs appear in logs, browser history, and referrer headers. " + - "Use Authorization: Bearer or X-Moltbot-Token header instead.", - ); - } - if (req.method !== "POST") { res.statusCode = 405; res.setHeader("Allow", "POST"); @@ -107,15 +93,58 @@ export function createHooksRequestHandler( return true; } - const body = await readJsonBody(req, hooksConfig.maxBodyBytes); - if (!body.ok) { - const status = body.error === "payload too large" ? 413 : 400; - sendJson(res, status, { ok: false, error: body.error }); + const headers = normalizeHookHeaders(req); + + // Read and parse body + const rawBody = await readRawBody(req, hooksConfig.maxBodyBytes); + if (!rawBody.ok) { + const status = rawBody.error === "payload too large" ? 413 : 400; + sendJson(res, status, { ok: false, error: rawBody.error }); + return true; + } + const parsed = parseJsonBody(rawBody.value); + if (!parsed.ok) { + sendJson(res, 400, { ok: false, error: parsed.error }); return true; } - const payload = typeof body.value === "object" && body.value !== null ? body.value : {}; - const headers = normalizeHookHeaders(req); + const payload = + typeof parsed.value === "object" && parsed.value !== null + ? (parsed.value as Record) + : {}; + + // Find matching mapping + const matched = findMapping(hooksConfig.mappings, { payload, headers, url, path: subPath }); + + // Authenticate (custom verifyAuth or token) + const auth = await authenticateHook({ + req, + url, + subPath, + headers, + rawBody: rawBody.value, + transform: matched?.transform, + expectedToken: hooksConfig.token, + }); + + if (!auth.ok) { + if (auth.status === 401) { + res.statusCode = 401; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Unauthorized"); + } else { + sendJson(res, auth.status, { ok: false, error: auth.error }); + } + return true; + } + + if (auth.tokenFromQuery) { + logHooks.warn( + "Hook token provided via query parameter is deprecated for security reasons. " + + "Tokens in URLs appear in logs, browser history, and referrer headers. " + + "Use Authorization: Bearer or X-Moltbot-Token header instead.", + ); + } if (subPath === "wake") { const normalized = normalizeWakePayload(payload as Record); @@ -139,9 +168,9 @@ export function createHooksRequestHandler( return true; } - if (hooksConfig.mappings.length > 0) { + if (matched) { try { - const mapped = await applyHookMappings(hooksConfig.mappings, { + const mapped = await applyMapping(matched, { payload: payload as Record, headers, url,