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`.
|
- `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.
|
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
|
## Responses
|
||||||
|
|
||||||
- `200` for `/hooks/wake`
|
- `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"}]}'
|
-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
|
## Security
|
||||||
|
|
||||||
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
|
- 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 path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
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");
|
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" }] },
|
payload: { messages: [{ subject: "Hello" }] },
|
||||||
headers: {},
|
headers: {},
|
||||||
url: baseUrl,
|
url: baseUrl,
|
||||||
@ -50,7 +67,7 @@ describe("hooks mapping", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const result = await applyHookMappings(mappings, {
|
const result = await applyFirstMatch(mappings, {
|
||||||
payload: { messages: [{ subject: "Hello" }] },
|
payload: { messages: [{ subject: "Hello" }] },
|
||||||
headers: {},
|
headers: {},
|
||||||
url: baseUrl,
|
url: baseUrl,
|
||||||
@ -82,7 +99,7 @@ describe("hooks mapping", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await applyHookMappings(mappings, {
|
const result = await applyFirstMatch(mappings, {
|
||||||
payload: { name: "Ada" },
|
payload: { name: "Ada" },
|
||||||
headers: {},
|
headers: {},
|
||||||
url: new URL("http://127.0.0.1:18789/hooks/custom"),
|
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: {},
|
payload: {},
|
||||||
headers: {},
|
headers: {},
|
||||||
url: new URL("http://127.0.0.1:18789/hooks/skip"),
|
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" }] },
|
payload: { messages: [{ subject: "Hello" }] },
|
||||||
headers: {},
|
headers: {},
|
||||||
url: baseUrl,
|
url: baseUrl,
|
||||||
@ -157,7 +174,7 @@ describe("hooks mapping", () => {
|
|||||||
const mappings = resolveHookMappings({
|
const mappings = resolveHookMappings({
|
||||||
mappings: [{ match: { path: "noop" }, action: "agent" }],
|
mappings: [{ match: { path: "noop" }, action: "agent" }],
|
||||||
});
|
});
|
||||||
const result = await applyHookMappings(mappings, {
|
const result = await applyFirstMatch(mappings, {
|
||||||
payload: {},
|
payload: {},
|
||||||
headers: {},
|
headers: {},
|
||||||
url: new URL("http://127.0.0.1:18789/hooks/noop"),
|
url: new URL("http://127.0.0.1:18789/hooks/noop"),
|
||||||
@ -166,3 +183,73 @@ describe("hooks mapping", () => {
|
|||||||
expect(result?.ok).toBe(false);
|
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<{
|
type HookTransformResult = Partial<{
|
||||||
kind: HookAction["kind"];
|
kind: HookAction["kind"];
|
||||||
@ -129,32 +152,33 @@ export function resolveHookMappings(hooks?: HooksConfig): HookMappingResolved[]
|
|||||||
return mappings.map((mapping, index) => normalizeHookMapping(mapping, index, transformsDir));
|
return mappings.map((mapping, index) => normalizeHookMapping(mapping, index, transformsDir));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyHookMappings(
|
export function findMapping(
|
||||||
mappings: HookMappingResolved[],
|
mappings: HookMappingResolved[],
|
||||||
ctx: HookMappingContext,
|
ctx: HookMappingContext,
|
||||||
): Promise<HookMappingResult | null> {
|
): HookMappingResolved | undefined {
|
||||||
if (mappings.length === 0) return null;
|
return mappings.find((m) => mappingMatches(m, ctx));
|
||||||
for (const mapping of mappings) {
|
}
|
||||||
if (!mappingMatches(mapping, ctx)) continue;
|
|
||||||
|
|
||||||
const base = buildActionFromMapping(mapping, ctx);
|
export async function applyMapping(
|
||||||
if (!base.ok) return base;
|
mapping: HookMappingResolved,
|
||||||
|
ctx: HookMappingContext,
|
||||||
|
): Promise<HookMappingResult> {
|
||||||
|
const base = buildActionFromMapping(mapping, ctx);
|
||||||
|
if (!base.ok) return base;
|
||||||
|
|
||||||
let override: HookTransformResult = null;
|
let override: HookTransformResult = null;
|
||||||
if (mapping.transform) {
|
if (mapping.transform) {
|
||||||
const transform = await loadTransform(mapping.transform);
|
const transform = await loadTransform(mapping.transform);
|
||||||
override = await transform(ctx);
|
override = await transform(ctx);
|
||||||
if (override === null) {
|
if (override === null) {
|
||||||
return { ok: true, action: null, skipped: true };
|
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(
|
function normalizeHookMapping(
|
||||||
@ -293,14 +317,42 @@ function validateAction(action: HookAction): HookMappingResult {
|
|||||||
return { ok: true, action };
|
return { ok: true, action };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTransform(transform: HookMappingTransformResolved): Promise<HookTransformFn> {
|
async function loadTransformModule(
|
||||||
|
transform: HookMappingTransformResolved,
|
||||||
|
): Promise<CachedTransform> {
|
||||||
const cached = transformCache.get(transform.modulePath);
|
const cached = transformCache.get(transform.modulePath);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const url = pathToFileURL(transform.modulePath).href;
|
const url = pathToFileURL(transform.modulePath).href;
|
||||||
const mod = (await import(url)) as Record<string, unknown>;
|
const mod = (await import(url)) as Record<string, unknown>;
|
||||||
const fn = resolveTransformFn(mod, transform.exportName);
|
const transformFn = resolveTransformFn(mod, transform.exportName);
|
||||||
transformCache.set(transform.modulePath, fn);
|
const verifyAuth = resolveVerifyFn(mod);
|
||||||
return fn;
|
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 {
|
function resolveTransformFn(mod: Record<string, unknown>, exportName?: string): HookTransformFn {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { IncomingMessage } from "node:http";
|
import type { IncomingMessage } from "node:http";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||||
@ -8,7 +9,10 @@ import {
|
|||||||
extractHookToken,
|
extractHookToken,
|
||||||
normalizeAgentPayload,
|
normalizeAgentPayload,
|
||||||
normalizeWakePayload,
|
normalizeWakePayload,
|
||||||
|
parseJsonBody,
|
||||||
|
readRawBody,
|
||||||
resolveHooksConfig,
|
resolveHooksConfig,
|
||||||
|
authenticateHook,
|
||||||
} from "./hooks.js";
|
} from "./hooks.js";
|
||||||
|
|
||||||
describe("gateway hooks helpers", () => {
|
describe("gateway hooks helpers", () => {
|
||||||
@ -150,3 +154,92 @@ const createMSTeamsPlugin = (params: { aliases?: string[] }): ChannelPlugin => (
|
|||||||
resolveAccount: () => ({}),
|
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 { ChannelId } from "../channels/plugins/types.js";
|
||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
import { normalizeMessageChannel } from "../utils/message-channel.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_PATH = "/hooks";
|
||||||
const DEFAULT_HOOKS_MAX_BODY_BYTES = 256 * 1024;
|
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 };
|
return { token: undefined, fromQuery: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readJsonBody(
|
export async function readRawBody(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
maxBytes: number,
|
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) => {
|
return await new Promise((resolve) => {
|
||||||
let done = false;
|
let done = false;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@ -83,17 +89,7 @@ export async function readJsonBody(
|
|||||||
req.on("end", () => {
|
req.on("end", () => {
|
||||||
if (done) return;
|
if (done) return;
|
||||||
done = true;
|
done = true;
|
||||||
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
resolve({ ok: true, value: Buffer.concat(chunks) });
|
||||||
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) });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
req.on("error", (err) => {
|
req.on("error", (err) => {
|
||||||
if (done) return;
|
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) {
|
export function normalizeHookHeaders(req: IncomingMessage) {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
for (const [key, value] of Object.entries(req.headers)) {
|
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 { resolveAgentAvatar } from "../agents/identity-avatar.js";
|
||||||
import { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js";
|
import { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js";
|
||||||
import {
|
import {
|
||||||
extractHookToken,
|
authenticateHook,
|
||||||
getHookChannelError,
|
getHookChannelError,
|
||||||
type HookMessageChannel,
|
type HookMessageChannel,
|
||||||
type HooksConfigResolved,
|
type HooksConfigResolved,
|
||||||
normalizeAgentPayload,
|
normalizeAgentPayload,
|
||||||
normalizeHookHeaders,
|
normalizeHookHeaders,
|
||||||
normalizeWakePayload,
|
normalizeWakePayload,
|
||||||
readJsonBody,
|
parseJsonBody,
|
||||||
|
readRawBody,
|
||||||
resolveHookChannel,
|
resolveHookChannel,
|
||||||
resolveHookDeliver,
|
resolveHookDeliver,
|
||||||
} from "./hooks.js";
|
} from "./hooks.js";
|
||||||
import { applyHookMappings } from "./hooks-mapping.js";
|
import { applyMapping, findMapping } from "./hooks-mapping.js";
|
||||||
import { handleOpenAiHttpRequest } from "./openai-http.js";
|
import { handleOpenAiHttpRequest } from "./openai-http.js";
|
||||||
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
|
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
|
||||||
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
|
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
|
||||||
@ -76,21 +77,6 @@ export function createHooksRequestHandler(
|
|||||||
return false;
|
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") {
|
if (req.method !== "POST") {
|
||||||
res.statusCode = 405;
|
res.statusCode = 405;
|
||||||
res.setHeader("Allow", "POST");
|
res.setHeader("Allow", "POST");
|
||||||
@ -107,15 +93,58 @@ export function createHooksRequestHandler(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await readJsonBody(req, hooksConfig.maxBodyBytes);
|
const headers = normalizeHookHeaders(req);
|
||||||
if (!body.ok) {
|
|
||||||
const status = body.error === "payload too large" ? 413 : 400;
|
// Read and parse body
|
||||||
sendJson(res, status, { ok: false, error: body.error });
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = typeof body.value === "object" && body.value !== null ? body.value : {};
|
const payload =
|
||||||
const headers = normalizeHookHeaders(req);
|
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") {
|
if (subPath === "wake") {
|
||||||
const normalized = normalizeWakePayload(payload as Record<string, unknown>);
|
const normalized = normalizeWakePayload(payload as Record<string, unknown>);
|
||||||
@ -139,9 +168,9 @@ export function createHooksRequestHandler(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hooksConfig.mappings.length > 0) {
|
if (matched) {
|
||||||
try {
|
try {
|
||||||
const mapped = await applyHookMappings(hooksConfig.mappings, {
|
const mapped = await applyMapping(matched, {
|
||||||
payload: payload as Record<string, unknown>,
|
payload: payload as Record<string, unknown>,
|
||||||
headers,
|
headers,
|
||||||
url,
|
url,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user