Merge 45b41672c6 into 4583f88626
This commit is contained in:
commit
ddca956e16
@ -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<string, unknown>; // Parsed JSON body
|
||||
headers: Record<string, string>; // 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<string, string>; // 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.
|
||||
|
||||
@ -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<typeof resolveHookMappings>,
|
||||
ctx: HookMappingContext,
|
||||
): Promise<HookMappingResult | null> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -77,7 +77,30 @@ const hookPresetMappings: Record<string, HookMappingConfig[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
const transformCache = new Map<string, HookTransformFn>();
|
||||
/**
|
||||
* 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<string, string>;
|
||||
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<boolean>;
|
||||
|
||||
type CachedTransform = {
|
||||
transform: HookTransformFn;
|
||||
verifyAuth?: HookVerifyAuthFn;
|
||||
};
|
||||
|
||||
const transformCache = new Map<string, CachedTransform>();
|
||||
|
||||
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<HookMappingResult | null> {
|
||||
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<HookMappingResult> {
|
||||
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<HookTransformFn> {
|
||||
async function loadTransformModule(
|
||||
transform: HookMappingTransformResolved,
|
||||
): Promise<CachedTransform> {
|
||||
const cached = transformCache.get(transform.modulePath);
|
||||
if (cached) return cached;
|
||||
const url = pathToFileURL(transform.modulePath).href;
|
||||
const mod = (await import(url)) as Record<string, unknown>;
|
||||
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<HookTransformFn> {
|
||||
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<HookVerifyAuthFn | undefined> {
|
||||
const mod = await loadTransformModule(transform);
|
||||
return mod.verifyAuth;
|
||||
}
|
||||
|
||||
function resolveVerifyFn(mod: Record<string, unknown>): 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<string, unknown>, exportName?: string): HookTransformFn {
|
||||
|
||||
@ -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<string, string> = {}): 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<string, string>;
|
||||
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<AuthenticateHookResult> {
|
||||
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<AuthenticateHookContext, "headers" | "url" | "subPath" | "rawBody">,
|
||||
): Promise<AuthenticateHookResult | undefined> {
|
||||
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<string, string> = {};
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
|
||||
@ -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 <token> 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<string, unknown>)
|
||||
: {};
|
||||
|
||||
// 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 <token> or X-Moltbot-Token header instead.",
|
||||
);
|
||||
}
|
||||
|
||||
if (subPath === "wake") {
|
||||
const normalized = normalizeWakePayload(payload as Record<string, unknown>);
|
||||
@ -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<string, unknown>,
|
||||
headers,
|
||||
url,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user