Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
// Webhooks API module exposes the plugin public contract.
export {
definePluginEntry,
type OpenClawPluginApi,
type PluginLogger,
type PluginRuntime,
} from "openclaw/plugin-sdk/core";

View File

@@ -0,0 +1,75 @@
// Webhooks tests cover index plugin behavior.
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawPluginApi } from "./api.js";
import plugin from "./index.js";
function createApi(params?: {
pluginConfig?: OpenClawPluginApi["pluginConfig"];
registerHttpRoute?: OpenClawPluginApi["registerHttpRoute"];
logger?: OpenClawPluginApi["logger"];
}): OpenClawPluginApi {
return createTestPluginApi({
id: "webhooks",
name: "Webhooks",
source: "test",
pluginConfig: params?.pluginConfig ?? {},
runtime: {
tasks: {
managedFlows: {
bindSession: vi.fn(({ sessionKey }: { sessionKey: string }) => ({ sessionKey })),
},
},
} as unknown as OpenClawPluginApi["runtime"],
registerHttpRoute: params?.registerHttpRoute ?? vi.fn(),
logger:
params?.logger ??
({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as OpenClawPluginApi["logger"]),
});
}
function requireFirstRouteRegistration(mock: ReturnType<typeof vi.fn>) {
const [call] = mock.mock.calls;
if (!call) {
throw new Error("expected webhook route registration");
}
return call[0] as Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
}
describe("webhooks plugin registration", () => {
it("registers SecretRef-backed routes synchronously", () => {
const registerHttpRoute = vi.fn();
const result = plugin.register(
createApi({
pluginConfig: {
routes: {
zapier: {
sessionKey: "agent:main:main",
secret: {
source: "env",
provider: "default",
id: "OPENCLAW_WEBHOOK_SECRET",
},
},
},
},
registerHttpRoute,
}),
);
expect(result).toBeUndefined();
expect(registerHttpRoute).toHaveBeenCalledTimes(1);
const route = requireFirstRouteRegistration(registerHttpRoute);
expect(route.path).toBe("/plugins/webhooks/zapier");
expect(route.auth).toBe("plugin");
expect(route.match).toBe("exact");
expect(route.replaceExisting).toBe(true);
expect(route.handler).toBeTypeOf("function");
});
});

View File

@@ -0,0 +1,54 @@
// Webhooks plugin entrypoint registers its OpenClaw integration.
import { definePluginEntry, type OpenClawPluginApi } from "./api.js";
import { resolveWebhooksPluginConfig } from "./src/config.js";
import { createTaskFlowWebhookRequestHandler, type TaskFlowWebhookTarget } from "./src/http.js";
function registerWebhookRoutes(api: OpenClawPluginApi): void {
const routes = resolveWebhooksPluginConfig({
pluginConfig: api.pluginConfig,
});
if (routes.length === 0) {
return;
}
const targetsByPath = new Map<string, TaskFlowWebhookTarget[]>();
const handler = createTaskFlowWebhookRequestHandler({
cfg: api.config,
targetsByPath,
});
for (const route of routes) {
const taskFlow = api.runtime.tasks.managedFlows.bindSession({
sessionKey: route.sessionKey,
});
const target: TaskFlowWebhookTarget = {
routeId: route.routeId,
path: route.path,
secretInput: route.secret,
secretConfigPath: `plugins.entries.webhooks.routes.${route.routeId}.secret`,
defaultControllerId: route.controllerId,
taskFlow,
};
targetsByPath.set(target.path, [...(targetsByPath.get(target.path) ?? []), target]);
api.registerHttpRoute({
path: target.path,
auth: "plugin",
match: "exact",
replaceExisting: true,
handler,
});
api.logger.info?.(
`[webhooks] registered route ${route.routeId} on ${route.path} for session ${route.sessionKey}`,
);
}
}
export default definePluginEntry({
id: "webhooks",
name: "Webhooks",
description:
"Authenticated inbound webhooks that bind external automation to OpenClaw TaskFlows.",
register(api: OpenClawPluginApi) {
registerWebhookRoutes(api);
},
});

View File

@@ -0,0 +1,51 @@
{
"id": "webhooks",
"activation": {
"onStartup": true
},
"name": "Webhooks",
"description": "Authenticated inbound webhooks that bind external automation to OpenClaw TaskFlows.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"$defs": {
"secretRef": {
"type": "object",
"additionalProperties": false,
"properties": {
"source": {
"type": "string",
"enum": ["env", "file", "exec"]
},
"provider": { "type": "string" },
"id": { "type": "string" }
},
"required": ["source", "provider", "id"]
},
"secretInput": {
"anyOf": [{ "type": "string", "minLength": 1 }, { "$ref": "#/$defs/secretRef" }]
},
"route": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"path": { "type": "string", "minLength": 1 },
"sessionKey": { "type": "string", "minLength": 1 },
"secret": { "$ref": "#/$defs/secretInput" },
"controllerId": { "type": "string", "minLength": 1 },
"description": { "type": "string" }
},
"required": ["sessionKey", "secret"]
}
},
"properties": {
"routes": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/route"
}
}
}
}
}

View File

@@ -0,0 +1,18 @@
{
"name": "@openclaw/webhooks",
"version": "2026.6.11",
"private": true,
"description": "OpenClaw webhook bridge plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
},
"dependencies": {
"zod": "4.4.3"
}
}

View File

@@ -0,0 +1,16 @@
// Webhooks API module exposes the plugin public contract.
export {
createFixedWindowRateLimiter,
createWebhookInFlightLimiter,
normalizeWebhookPath,
readJsonWebhookBodyOrReject,
resolveRequestClientIp,
resolveWebhookTargetWithAuthOrReject,
resolveWebhookTargetWithAuthOrRejectSync,
withResolvedWebhookRequestPipeline,
WEBHOOK_IN_FLIGHT_DEFAULTS,
WEBHOOK_RATE_LIMIT_DEFAULTS,
type WebhookInFlightLimiter,
} from "openclaw/plugin-sdk/webhook-ingress";
export { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime";
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";

View File

@@ -0,0 +1,88 @@
// Webhooks tests cover config plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveWebhooksPluginConfig } from "./config.js";
describe("resolveWebhooksPluginConfig", () => {
it("keeps SecretRef-backed secrets on the route config", () => {
const routes = resolveWebhooksPluginConfig({
pluginConfig: {
routes: {
zapier: {
sessionKey: "agent:main:main",
secret: {
source: "env",
provider: "default",
id: "OPENCLAW_WEBHOOK_SECRET",
},
},
},
},
});
expect(routes).toEqual([
{
routeId: "zapier",
path: "/plugins/webhooks/zapier",
sessionKey: "agent:main:main",
secret: {
source: "env",
provider: "default",
id: "OPENCLAW_WEBHOOK_SECRET",
},
controllerId: "webhooks/zapier",
},
]);
});
it("keeps routes whose secret needs runtime resolution", () => {
const routes = resolveWebhooksPluginConfig({
pluginConfig: {
routes: {
missing: {
sessionKey: "agent:main:main",
secret: {
source: "env",
provider: "default",
id: "MISSING_SECRET",
},
},
},
},
});
expect(routes).toEqual([
{
routeId: "missing",
path: "/plugins/webhooks/missing",
sessionKey: "agent:main:main",
secret: {
source: "env",
provider: "default",
id: "MISSING_SECRET",
},
controllerId: "webhooks/missing",
},
]);
});
it("rejects duplicate normalized paths", () => {
expect(() =>
resolveWebhooksPluginConfig({
pluginConfig: {
routes: {
first: {
path: "/plugins/webhooks/shared",
sessionKey: "agent:main:main",
secret: "a",
},
second: {
path: "/plugins/webhooks/shared/",
sessionKey: "agent:main:other",
secret: "b",
},
},
},
}),
).toThrow(/conflicts with routes\.first\.path/i);
});
});

View File

@@ -0,0 +1,74 @@
// Webhooks helper module supports config behavior.
import { z } from "zod";
import { normalizeWebhookPath } from "../runtime-api.js";
const secretRefSchema = z
.object({
source: z.enum(["env", "file", "exec"]),
provider: z.string().trim().min(1),
id: z.string().trim().min(1),
})
.strict();
const secretInputSchema = z.union([z.string().trim().min(1), secretRefSchema]);
const webhookRouteConfigSchema = z
.object({
enabled: z.boolean().optional().default(true),
path: z.string().trim().min(1).optional(),
sessionKey: z.string().trim().min(1),
secret: secretInputSchema,
controllerId: z.string().trim().min(1).optional(),
description: z.string().trim().min(1).optional(),
})
.strict();
const webhooksPluginConfigSchema = z
.object({
routes: z.record(z.string().trim().min(1), webhookRouteConfigSchema).default({}),
})
.strict();
export type WebhookSecretInput = z.infer<typeof secretInputSchema>;
type ConfiguredWebhookRouteConfig = {
routeId: string;
path: string;
sessionKey: string;
secret: WebhookSecretInput;
controllerId: string;
description?: string;
};
export function resolveWebhooksPluginConfig(params: {
pluginConfig: unknown;
}): ConfiguredWebhookRouteConfig[] {
const parsed = webhooksPluginConfigSchema.parse(params.pluginConfig ?? {});
const configuredRoutes: ConfiguredWebhookRouteConfig[] = [];
const seenPaths = new Map<string, string>();
for (const [routeId, route] of Object.entries(parsed.routes)) {
if (!route.enabled) {
continue;
}
const path = normalizeWebhookPath(route.path ?? `/plugins/webhooks/${routeId}`);
const existingRouteId = seenPaths.get(path);
if (existingRouteId) {
throw new Error(
`webhooks.routes.${routeId}.path conflicts with routes.${existingRouteId}.path (${path}).`,
);
}
seenPaths.set(path, routeId);
configuredRoutes.push({
routeId,
path,
sessionKey: route.sessionKey,
secret: route.secret,
controllerId: route.controllerId ?? `webhooks/${routeId}`,
...(route.description ? { description: route.description } : {}),
});
}
return configuredRoutes;
}

View File

@@ -0,0 +1,435 @@
// Webhooks tests cover http plugin behavior.
import { EventEmitter } from "node:events";
import type { IncomingMessage } from "node:http";
import { createRuntimeTaskFlow } from "openclaw/plugin-sdk/plugin-test-runtime";
import { createMockServerResponse } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import { createTaskFlowWebhookRequestHandler, type TaskFlowWebhookTarget } from "./http.js";
type BoundTaskFlow = TaskFlowWebhookTarget["taskFlow"];
type ManagedFlow = NonNullable<ReturnType<BoundTaskFlow["createManaged"]>>;
function createManagedFlow(
target: TaskFlowWebhookTarget,
params: Parameters<BoundTaskFlow["createManaged"]>[0],
): ManagedFlow {
const flow = target.taskFlow.createManaged(params);
if (!flow) {
throw new Error("expected managed TaskFlow creation to succeed");
}
return flow;
}
const hoisted = vi.hoisted(() => {
const resolveConfiguredSecretInputStringMock = vi.fn();
return {
resolveConfiguredSecretInputStringMock,
};
});
vi.mock("../runtime-api.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../runtime-api.js")>();
hoisted.resolveConfiguredSecretInputStringMock.mockImplementation(
actual.resolveConfiguredSecretInputString,
);
return {
...actual,
resolveConfiguredSecretInputString: hoisted.resolveConfiguredSecretInputStringMock,
};
});
type MockIncomingMessage = IncomingMessage & {
destroyed?: boolean;
destroy: () => MockIncomingMessage;
socket: { remoteAddress: string };
};
let nextSessionId = 0;
function createJsonRequest(params: {
path: string;
secret?: string;
body: unknown;
}): MockIncomingMessage {
const req = new EventEmitter() as MockIncomingMessage;
req.method = "POST";
req.url = params.path;
req.headers = {
"content-type": "application/json",
...(params.secret ? { "x-openclaw-webhook-secret": params.secret } : {}),
};
req.socket = { remoteAddress: "127.0.0.1" } as MockIncomingMessage["socket"];
req.destroyed = false;
req.destroy = (() => {
req.destroyed = true;
return req;
}) as MockIncomingMessage["destroy"];
setImmediate(() => {
req.emit("data", Buffer.from(JSON.stringify(params.body), "utf8"));
req.emit("end");
});
return req;
}
function createHandler(): {
handler: ReturnType<typeof createTaskFlowWebhookRequestHandler>;
target: TaskFlowWebhookTarget;
secret: string;
} {
const runtime = createRuntimeTaskFlow();
nextSessionId += 1;
const secret = "shared-secret";
const target: TaskFlowWebhookTarget = {
routeId: "zapier",
path: "/plugins/webhooks/zapier",
secretInput: secret,
secretConfigPath: "plugins.entries.webhooks.routes.zapier.secret",
defaultControllerId: "webhooks/zapier",
taskFlow: runtime.bindSession({
sessionKey: `agent:main:webhook-test-${String(nextSessionId)}`,
}),
};
const targetsByPath = new Map<string, TaskFlowWebhookTarget[]>([[target.path, [target]]]);
return {
handler: createTaskFlowWebhookRequestHandler({
cfg: {} as OpenClawConfig,
targetsByPath,
}),
target,
secret,
};
}
function createHandlerWithTarget(
target: TaskFlowWebhookTarget,
cfg: OpenClawConfig = {} as OpenClawConfig,
): ReturnType<typeof createTaskFlowWebhookRequestHandler> {
const targetsByPath = new Map<string, TaskFlowWebhookTarget[]>([[target.path, [target]]]);
return createTaskFlowWebhookRequestHandler({
cfg,
targetsByPath,
});
}
async function dispatchJsonRequest(params: {
handler: ReturnType<typeof createTaskFlowWebhookRequestHandler>;
path: string;
secret?: string;
body: unknown;
}) {
const req = createJsonRequest({
path: params.path,
secret: params.secret,
body: params.body,
});
const res = createMockServerResponse();
await params.handler(req, res);
return res;
}
function parseJsonBody(res: { body?: string | Buffer | null }) {
return JSON.parse(String(res.body ?? ""));
}
afterEach(() => {
vi.clearAllMocks();
});
describe("createTaskFlowWebhookRequestHandler", () => {
it("rejects requests with the wrong secret", async () => {
const { handler, target } = createHandler();
const res = await dispatchJsonRequest({
handler,
path: target.path,
secret: "wrong-secret",
body: {
action: "list_flows",
},
});
expect(res.statusCode).toBe(401);
expect(res.body).toBe("unauthorized");
expect(target.taskFlow.list()).toStrictEqual([]);
expect(hoisted.resolveConfiguredSecretInputStringMock).not.toHaveBeenCalled();
});
it("re-resolves SecretRef-backed secrets across requests", async () => {
const runtime = createRuntimeTaskFlow();
const target: TaskFlowWebhookTarget = {
routeId: "cached",
path: "/plugins/webhooks/cached",
secretInput: {
source: "env",
provider: "default",
id: "OPENCLAW_WEBHOOK_SECRET",
},
secretConfigPath: "plugins.entries.webhooks.routes.cached.secret",
defaultControllerId: "webhooks/cached",
taskFlow: runtime.bindSession({
sessionKey: "agent:main:webhook-cached",
}),
};
hoisted.resolveConfiguredSecretInputStringMock
.mockResolvedValueOnce({ value: "shared-secret" })
.mockResolvedValueOnce({ value: "rotated-secret" })
.mockResolvedValueOnce({ value: "rotated-secret" });
const handler = createHandlerWithTarget(target);
const first = await dispatchJsonRequest({
handler,
path: target.path,
secret: "shared-secret",
body: {
action: "list_flows",
},
});
const second = await dispatchJsonRequest({
handler,
path: target.path,
secret: "shared-secret",
body: {
action: "list_flows",
},
});
const third = await dispatchJsonRequest({
handler,
path: target.path,
secret: "rotated-secret",
body: {
action: "list_flows",
},
});
expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(401);
expect(second.body).toBe("unauthorized");
expect(third.statusCode).toBe(200);
expect(hoisted.resolveConfiguredSecretInputStringMock).toHaveBeenCalledTimes(3);
});
it("creates flows through the bound session and scrubs owner metadata from responses", async () => {
const { handler, target, secret } = createHandler();
const res = await dispatchJsonRequest({
handler,
path: target.path,
secret,
body: {
action: "create_flow",
goal: "Review inbound queue",
},
});
expect(res.statusCode).toBe(200);
const parsed = parseJsonBody(res);
expect(parsed.ok).toBe(true);
expect(parsed.result.flow.syncMode).toBe("managed");
expect(parsed.result.flow.controllerId).toBe("webhooks/zapier");
expect(parsed.result.flow.goal).toBe("Review inbound queue");
expect(parsed.result.flow.ownerKey).toBeUndefined();
expect(parsed.result.flow.requesterOrigin).toBeUndefined();
expect(target.taskFlow.get(parsed.result.flow.flowId)?.flowId).toBe(parsed.result.flow.flowId);
});
it("runs child tasks and scrubs task ownership fields from responses", async () => {
const { handler, target, secret } = createHandler();
const flow = createManagedFlow(target, {
controllerId: "webhooks/zapier",
goal: "Triage inbox",
});
const res = await dispatchJsonRequest({
handler,
path: target.path,
secret,
body: {
action: "run_task",
flowId: flow.flowId,
runtime: "acp",
childSessionKey: "agent:main:subagent:child",
task: "Inspect the next message batch",
status: "running",
startedAt: 10,
lastEventAt: 10,
},
});
expect(res.statusCode).toBe(200);
const parsed = parseJsonBody(res);
expect(parsed.ok).toBe(true);
expect(parsed.result.created).toBe(true);
expect(parsed.result.task.parentFlowId).toBe(flow.flowId);
expect(parsed.result.task.childSessionKey).toBe("agent:main:subagent:child");
expect(parsed.result.task.runtime).toBe("acp");
expect(parsed.result.task.ownerKey).toBeUndefined();
expect(parsed.result.task.requesterSessionKey).toBeUndefined();
});
it("returns 404 for missing flow mutations", async () => {
const { handler, target, secret } = createHandler();
const res = await dispatchJsonRequest({
handler,
path: target.path,
secret,
body: {
action: "set_waiting",
flowId: "flow-missing",
expectedRevision: 0,
},
});
expect(res.statusCode).toBe(404);
const parsed = parseJsonBody(res);
expect(parsed.ok).toBe(false);
expect(parsed.code).toBe("not_found");
expect(parsed.error).toBe("TaskFlow not found.");
expect(parsed.result.applied).toBe(false);
expect(parsed.result.code).toBe("not_found");
});
it("returns 409 for revision conflicts", async () => {
const { handler, target, secret } = createHandler();
const flow = createManagedFlow(target, {
controllerId: "webhooks/zapier",
goal: "Review inbox",
});
const res = await dispatchJsonRequest({
handler,
path: target.path,
secret,
body: {
action: "set_waiting",
flowId: flow.flowId,
expectedRevision: flow.revision + 1,
},
});
expect(res.statusCode).toBe(409);
const parsed = parseJsonBody(res);
expect(parsed.ok).toBe(false);
expect(parsed.code).toBe("revision_conflict");
expect(parsed.result.applied).toBe(false);
expect(parsed.result.code).toBe("revision_conflict");
expect(parsed.result.current.flowId).toBe(flow.flowId);
expect(parsed.result.current.revision).toBe(flow.revision);
});
it("rejects internal runtimes and running-only metadata from external callers", async () => {
const { handler, target, secret } = createHandler();
const flow = createManagedFlow(target, {
controllerId: "webhooks/zapier",
goal: "Review inbox",
});
const runtimeRes = await dispatchJsonRequest({
handler,
path: target.path,
secret,
body: {
action: "run_task",
flowId: flow.flowId,
runtime: "cli",
task: "Inspect queue",
},
});
expect(runtimeRes.statusCode).toBe(400);
const runtimeParsed = parseJsonBody(runtimeRes);
expect(runtimeParsed.ok).toBe(false);
expect(runtimeParsed.code).toBe("invalid_request");
const queuedMetadataRes = await dispatchJsonRequest({
handler,
path: target.path,
secret,
body: {
action: "run_task",
flowId: flow.flowId,
runtime: "acp",
task: "Inspect queue",
startedAt: 10,
},
});
expect(queuedMetadataRes.statusCode).toBe(400);
const queuedMetadataParsed = parseJsonBody(queuedMetadataRes);
expect(queuedMetadataParsed.ok).toBe(false);
expect(queuedMetadataParsed.code).toBe("invalid_request");
expect(queuedMetadataParsed.error).toBe(
"status: status must be running when startedAt, lastEventAt, or progressSummary is provided",
);
});
it("reuses the same task record when retried with the same runId", async () => {
const { handler, target, secret } = createHandler();
const flow = createManagedFlow(target, {
controllerId: "webhooks/zapier",
goal: "Triage inbox",
});
const first = await dispatchJsonRequest({
handler,
path: target.path,
secret,
body: {
action: "run_task",
flowId: flow.flowId,
runtime: "acp",
childSessionKey: "agent:main:subagent:child",
runId: "retry-me",
task: "Inspect the next message batch",
},
});
const second = await dispatchJsonRequest({
handler,
path: target.path,
secret,
body: {
action: "run_task",
flowId: flow.flowId,
runtime: "acp",
childSessionKey: "agent:main:subagent:child",
runId: "retry-me",
task: "Inspect the next message batch",
},
});
expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(200);
const firstParsed = parseJsonBody(first);
const secondParsed = parseJsonBody(second);
expect(firstParsed.result.task.taskId).toBe(secondParsed.result.task.taskId);
expect(target.taskFlow.getTaskSummary(flow.flowId)?.total).toBe(1);
});
it("returns 409 when cancellation targets a terminal flow", async () => {
const { handler, target, secret } = createHandler();
const flow = createManagedFlow(target, {
controllerId: "webhooks/zapier",
goal: "Review inbox",
});
const finished = target.taskFlow.finish({
flowId: flow.flowId,
expectedRevision: flow.revision,
});
expect(finished.applied).toBe(true);
const res = await dispatchJsonRequest({
handler,
path: target.path,
secret,
body: {
action: "cancel_flow",
flowId: flow.flowId,
},
});
expect(res.statusCode).toBe(409);
const parsed = parseJsonBody(res);
expect(parsed.ok).toBe(false);
expect(parsed.code).toBe("terminal");
expect(parsed.error).toBe("Flow is already succeeded.");
expect(parsed.result.found).toBe(true);
expect(parsed.result.cancelled).toBe(false);
expect(parsed.result.reason).toBe("Flow is already succeeded.");
});
});

View File

@@ -0,0 +1,833 @@
// Webhooks plugin module implements http behavior.
import type { IncomingMessage, ServerResponse } from "node:http";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { z } from "zod";
import type { PluginRuntime } from "../api.js";
import {
createFixedWindowRateLimiter,
createWebhookInFlightLimiter,
readJsonWebhookBodyOrReject,
resolveRequestClientIp,
resolveConfiguredSecretInputString,
resolveWebhookTargetWithAuthOrReject,
withResolvedWebhookRequestPipeline,
WEBHOOK_IN_FLIGHT_DEFAULTS,
WEBHOOK_RATE_LIMIT_DEFAULTS,
type OpenClawConfig,
type WebhookInFlightLimiter,
} from "../runtime-api.js";
import type { WebhookSecretInput } from "./config.js";
type BoundTaskFlowRuntime = ReturnType<PluginRuntime["tasks"]["managedFlows"]["bindSession"]>;
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
const jsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
z.union([
z.null(),
z.boolean(),
z.number().finite(),
z.string(),
z.array(jsonValueSchema),
z.record(z.string(), jsonValueSchema),
]),
);
const nullableStringSchema = z.string().trim().min(1).nullable().optional();
const createFlowRequestSchema = z
.object({
action: z.literal("create_flow"),
controllerId: z.string().trim().min(1).optional(),
goal: z.string().trim().min(1),
status: z.enum(["queued", "running", "waiting", "blocked"]).optional(),
notifyPolicy: z.enum(["done_only", "state_changes", "silent"]).optional(),
currentStep: nullableStringSchema,
stateJson: jsonValueSchema.nullable().optional(),
waitJson: jsonValueSchema.nullable().optional(),
})
.strict();
const getFlowRequestSchema = z
.object({ action: z.literal("get_flow"), flowId: z.string().trim().min(1) })
.strict();
const listFlowsRequestSchema = z.object({ action: z.literal("list_flows") }).strict();
const findLatestFlowRequestSchema = z.object({ action: z.literal("find_latest_flow") }).strict();
const resolveFlowRequestSchema = z
.object({ action: z.literal("resolve_flow"), token: z.string().trim().min(1) })
.strict();
const getTaskSummaryRequestSchema = z
.object({ action: z.literal("get_task_summary"), flowId: z.string().trim().min(1) })
.strict();
const setWaitingRequestSchema = z
.object({
action: z.literal("set_waiting"),
flowId: z.string().trim().min(1),
expectedRevision: z.number().int().nonnegative(),
currentStep: nullableStringSchema,
stateJson: jsonValueSchema.nullable().optional(),
waitJson: jsonValueSchema.nullable().optional(),
blockedTaskId: nullableStringSchema,
blockedSummary: nullableStringSchema,
})
.strict();
const resumeFlowRequestSchema = z
.object({
action: z.literal("resume_flow"),
flowId: z.string().trim().min(1),
expectedRevision: z.number().int().nonnegative(),
status: z.enum(["queued", "running"]).optional(),
currentStep: nullableStringSchema,
stateJson: jsonValueSchema.nullable().optional(),
})
.strict();
const finishFlowRequestSchema = z
.object({
action: z.literal("finish_flow"),
flowId: z.string().trim().min(1),
expectedRevision: z.number().int().nonnegative(),
stateJson: jsonValueSchema.nullable().optional(),
})
.strict();
const failFlowRequestSchema = z
.object({
action: z.literal("fail_flow"),
flowId: z.string().trim().min(1),
expectedRevision: z.number().int().nonnegative(),
stateJson: jsonValueSchema.nullable().optional(),
blockedTaskId: nullableStringSchema,
blockedSummary: nullableStringSchema,
})
.strict();
const requestCancelRequestSchema = z
.object({
action: z.literal("request_cancel"),
flowId: z.string().trim().min(1),
expectedRevision: z.number().int().nonnegative(),
})
.strict();
const cancelFlowRequestSchema = z
.object({
action: z.literal("cancel_flow"),
flowId: z.string().trim().min(1),
})
.strict();
const runTaskRequestSchema = z
.object({
action: z.literal("run_task"),
flowId: z.string().trim().min(1),
runtime: z.enum(["subagent", "acp"]),
sourceId: z.string().trim().min(1).optional(),
childSessionKey: z.string().trim().min(1).optional(),
parentTaskId: z.string().trim().min(1).optional(),
agentId: z.string().trim().min(1).optional(),
runId: z.string().trim().min(1).optional(),
label: z.string().trim().min(1).optional(),
task: z.string().trim().min(1),
preferMetadata: z.boolean().optional(),
notifyPolicy: z.enum(["done_only", "state_changes", "silent"]).optional(),
status: z.enum(["queued", "running"]).optional(),
startedAt: z.number().int().nonnegative().optional(),
lastEventAt: z.number().int().nonnegative().optional(),
progressSummary: nullableStringSchema,
})
.strict()
.superRefine((value, ctx) => {
if (
value.status !== "running" &&
(value.startedAt !== undefined ||
value.lastEventAt !== undefined ||
value.progressSummary !== undefined)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"status must be running when startedAt, lastEventAt, or progressSummary is provided",
path: ["status"],
});
}
});
const webhookActionSchema = z.discriminatedUnion("action", [
createFlowRequestSchema,
getFlowRequestSchema,
listFlowsRequestSchema,
findLatestFlowRequestSchema,
resolveFlowRequestSchema,
getTaskSummaryRequestSchema,
setWaitingRequestSchema,
resumeFlowRequestSchema,
finishFlowRequestSchema,
failFlowRequestSchema,
requestCancelRequestSchema,
cancelFlowRequestSchema,
runTaskRequestSchema,
]);
type WebhookAction = z.infer<typeof webhookActionSchema>;
export type TaskFlowWebhookTarget = {
routeId: string;
path: string;
secretInput: WebhookSecretInput;
secretConfigPath: string;
defaultControllerId: string;
taskFlow: BoundTaskFlowRuntime;
};
type FlowView = {
flowId: string;
syncMode: "task_mirrored" | "managed";
controllerId?: string;
revision: number;
status: string;
notifyPolicy: string;
goal: string;
currentStep?: string;
blockedTaskId?: string;
blockedSummary?: string;
stateJson?: JsonValue;
waitJson?: JsonValue;
cancelRequestedAt?: number;
createdAt: number;
updatedAt: number;
endedAt?: number;
};
type TaskView = {
taskId: string;
runtime: string;
sourceId?: string;
scopeKind: string;
childSessionKey?: string;
parentFlowId?: string;
parentTaskId?: string;
agentId?: string;
runId?: string;
label?: string;
task: string;
status: string;
deliveryStatus: string;
notifyPolicy: string;
createdAt: number;
startedAt?: number;
endedAt?: number;
lastEventAt?: number;
cleanupAfter?: number;
error?: string;
progressSummary?: string;
terminalSummary?: string;
terminalOutcome?: string;
};
function pickOptionalFields<T extends object, TKey extends keyof T & string>(
source: T,
keys: readonly TKey[],
): Partial<Pick<T, TKey>> {
const result: Partial<Pick<T, TKey>> = {};
for (const key of keys) {
const value = source[key];
if (value !== undefined) {
result[key] = value;
}
}
return result;
}
function pickOptionalTruthyStringFields<T extends object, TKey extends keyof T & string>(
source: T,
keys: readonly TKey[],
): Partial<Pick<T, TKey>> {
const result: Partial<Pick<T, TKey>> = {};
for (const key of keys) {
const value = source[key];
if (typeof value === "string" && value) {
result[key] = value as T[TKey];
}
}
return result;
}
function toFlowView(flow: FlowView): FlowView {
return {
flowId: flow.flowId,
syncMode: flow.syncMode,
...pickOptionalTruthyStringFields(flow, [
"controllerId",
"currentStep",
"blockedTaskId",
"blockedSummary",
]),
revision: flow.revision,
status: flow.status,
notifyPolicy: flow.notifyPolicy,
goal: flow.goal,
...pickOptionalFields(flow, ["stateJson", "waitJson", "cancelRequestedAt"]),
createdAt: flow.createdAt,
updatedAt: flow.updatedAt,
...pickOptionalFields(flow, ["endedAt"]),
};
}
function toTaskView(task: TaskView): TaskView {
return {
taskId: task.taskId,
runtime: task.runtime,
...pickOptionalTruthyStringFields(task, [
"sourceId",
"childSessionKey",
"parentFlowId",
"parentTaskId",
"agentId",
"runId",
"label",
"error",
"progressSummary",
"terminalSummary",
"terminalOutcome",
]),
scopeKind: task.scopeKind,
task: task.task,
status: task.status,
deliveryStatus: task.deliveryStatus,
notifyPolicy: task.notifyPolicy,
createdAt: task.createdAt,
...pickOptionalFields(task, ["startedAt", "endedAt", "lastEventAt", "cleanupAfter"]),
};
}
function writeJson(res: ServerResponse, statusCode: number, body: unknown): void {
res.statusCode = statusCode;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
}
function extractSharedSecret(req: IncomingMessage): string {
const authHeader = Array.isArray(req.headers.authorization)
? (req.headers.authorization[0] ?? "")
: (req.headers.authorization ?? "");
if (normalizeLowercaseStringOrEmpty(authHeader).startsWith("bearer ")) {
return authHeader.slice("bearer ".length).trim();
}
const sharedHeader = req.headers["x-openclaw-webhook-secret"];
return Array.isArray(sharedHeader) ? (sharedHeader[0] ?? "").trim() : (sharedHeader ?? "").trim();
}
function formatZodError(error: z.ZodError): string {
const firstIssue = error.issues[0];
if (!firstIssue) {
return "invalid request";
}
const path = firstIssue.path.length > 0 ? `${firstIssue.path.join(".")}: ` : "";
return `${path}${firstIssue.message}`;
}
function mapMutationResult(
result:
| {
applied: true;
flow: FlowView;
}
| {
applied: false;
code: string;
current?: FlowView;
},
): unknown {
return result;
}
function mapFlowMutationResult(
result:
| {
applied: true;
flow: Parameters<typeof toFlowView>[0];
}
| {
applied: false;
code: string;
current?: Parameters<typeof toFlowView>[0];
},
): unknown {
return mapMutationResult(
result.applied
? { applied: true, flow: toFlowView(result.flow) }
: {
applied: false,
code: result.code,
...(result.current ? { current: toFlowView(result.current) } : {}),
},
);
}
function mapMutationStatus(result: {
applied: boolean;
code?: "not_found" | "not_managed" | "revision_conflict" | "persist_failed";
}): { statusCode: number; code?: string; error?: string } {
if (result.applied) {
return { statusCode: 200 };
}
switch (result.code) {
case "not_found":
return {
statusCode: 404,
code: "not_found",
error: "TaskFlow not found.",
};
case "not_managed":
return {
statusCode: 409,
code: "not_managed",
error: "TaskFlow is not managed by this webhook surface.",
};
case "revision_conflict":
return {
statusCode: 409,
code: "revision_conflict",
error: "TaskFlow changed since the caller's expected revision.",
};
case "persist_failed":
return {
statusCode: 503,
code: "persist_failed",
error: "TaskFlow persistence failed.",
};
default:
return {
statusCode: 409,
code: "mutation_rejected",
error: "TaskFlow mutation was rejected.",
};
}
}
function mapCreateFlowStatus(result: { created: boolean; code?: "persist_failed" }): {
statusCode: number;
code?: string;
error?: string;
} {
if (result.created) {
return { statusCode: 200 };
}
if (result.code === "persist_failed") {
return {
statusCode: 503,
code: "persist_failed",
error: "TaskFlow persistence failed.",
};
}
return {
statusCode: 409,
code: "create_rejected",
error: "TaskFlow creation was rejected.",
};
}
function mapRunTaskStatus(result: { created: boolean; found: boolean; reason?: string }): {
statusCode: number;
code?: string;
error?: string;
} {
if (result.created) {
return { statusCode: 200 };
}
if (!result.found) {
return {
statusCode: 404,
code: "not_found",
error: "TaskFlow not found.",
};
}
if (result.reason === "Flow cancellation has already been requested.") {
return {
statusCode: 409,
code: "cancel_requested",
error: result.reason,
};
}
if (result.reason === "Flow does not accept managed child tasks.") {
return {
statusCode: 409,
code: "not_managed",
error: result.reason,
};
}
if (result.reason?.startsWith("Flow is already ")) {
return {
statusCode: 409,
code: "terminal",
error: result.reason,
};
}
if (result.reason === "Task persistence failed.") {
return {
statusCode: 503,
code: "persist_failed",
error: result.reason,
};
}
return {
statusCode: 409,
code: "task_not_created",
error: result.reason ?? "TaskFlow task was not created.",
};
}
function mapCancelStatus(result: { found: boolean; cancelled: boolean; reason?: string }): {
statusCode: number;
code?: string;
error?: string;
} {
if (result.cancelled) {
return { statusCode: 200 };
}
if (!result.found) {
return {
statusCode: 404,
code: "not_found",
error: "TaskFlow not found.",
};
}
if (result.reason === "One or more child tasks are still active.") {
return {
statusCode: 202,
code: "cancel_pending",
error: result.reason,
};
}
if (result.reason === "Flow changed while cancellation was in progress.") {
return {
statusCode: 409,
code: "revision_conflict",
error: result.reason,
};
}
if (result.reason?.startsWith("Flow is already ")) {
return {
statusCode: 409,
code: "terminal",
error: result.reason,
};
}
if (result.reason === "Flow persistence failed.") {
return {
statusCode: 503,
code: "persist_failed",
error: result.reason,
};
}
return {
statusCode: 409,
code: "cancel_rejected",
error: result.reason ?? "TaskFlow cancellation was rejected.",
};
}
function describeWebhookOutcome(params: { action: WebhookAction; result: unknown }): {
statusCode: number;
code?: string;
error?: string;
} {
switch (params.action.action) {
case "create_flow":
return mapCreateFlowStatus(
params.result as {
created: boolean;
code?: "persist_failed";
},
);
case "set_waiting":
case "resume_flow":
case "finish_flow":
case "fail_flow":
case "request_cancel":
return mapMutationStatus(
params.result as {
applied: boolean;
code?: "not_found" | "not_managed" | "revision_conflict" | "persist_failed";
},
);
case "cancel_flow":
return mapCancelStatus(
params.result as {
found: boolean;
cancelled: boolean;
reason?: string;
},
);
case "run_task":
return mapRunTaskStatus(
params.result as {
created: boolean;
found: boolean;
reason?: string;
},
);
default:
return { statusCode: 200 };
}
}
async function executeWebhookAction(params: {
action: WebhookAction;
target: TaskFlowWebhookTarget;
cfg: OpenClawConfig;
}): Promise<unknown> {
const { action, target } = params;
switch (action.action) {
case "create_flow": {
const flow = target.taskFlow.tryCreateManaged({
controllerId: action.controllerId ?? target.defaultControllerId,
goal: action.goal,
status: action.status,
notifyPolicy: action.notifyPolicy,
currentStep: action.currentStep ?? undefined,
stateJson: action.stateJson,
waitJson: action.waitJson,
});
return flow
? { created: true, flow: toFlowView(flow) }
: { created: false, code: "persist_failed" };
}
case "get_flow": {
const flow = target.taskFlow.get(action.flowId);
return { flow: flow ? toFlowView(flow) : null };
}
case "list_flows":
return { flows: target.taskFlow.list().map(toFlowView) };
case "find_latest_flow": {
const flow = target.taskFlow.findLatest();
return { flow: flow ? toFlowView(flow) : null };
}
case "resolve_flow": {
const flow = target.taskFlow.resolve(action.token);
return { flow: flow ? toFlowView(flow) : null };
}
case "get_task_summary":
return { summary: target.taskFlow.getTaskSummary(action.flowId) ?? null };
case "set_waiting": {
const result = target.taskFlow.setWaiting({
flowId: action.flowId,
expectedRevision: action.expectedRevision,
currentStep: action.currentStep,
stateJson: action.stateJson,
waitJson: action.waitJson,
blockedTaskId: action.blockedTaskId,
blockedSummary: action.blockedSummary,
});
return mapFlowMutationResult(result);
}
case "resume_flow": {
const result = target.taskFlow.resume({
flowId: action.flowId,
expectedRevision: action.expectedRevision,
status: action.status,
currentStep: action.currentStep,
stateJson: action.stateJson,
});
return mapFlowMutationResult(result);
}
case "finish_flow": {
const result = target.taskFlow.finish({
flowId: action.flowId,
expectedRevision: action.expectedRevision,
stateJson: action.stateJson,
});
return mapFlowMutationResult(result);
}
case "fail_flow": {
const result = target.taskFlow.fail({
flowId: action.flowId,
expectedRevision: action.expectedRevision,
stateJson: action.stateJson,
blockedTaskId: action.blockedTaskId,
blockedSummary: action.blockedSummary,
});
return mapFlowMutationResult(result);
}
case "request_cancel": {
const result = target.taskFlow.requestCancel({
flowId: action.flowId,
expectedRevision: action.expectedRevision,
});
return mapFlowMutationResult(result);
}
case "cancel_flow": {
const result = await target.taskFlow.cancel({
flowId: action.flowId,
cfg: params.cfg,
});
return {
found: result.found,
cancelled: result.cancelled,
...(result.reason ? { reason: result.reason } : {}),
...(result.flow ? { flow: toFlowView(result.flow) } : {}),
...(result.tasks ? { tasks: result.tasks.map(toTaskView) } : {}),
};
}
case "run_task": {
const result = target.taskFlow.runTask({
flowId: action.flowId,
runtime: action.runtime,
sourceId: action.sourceId,
childSessionKey: action.childSessionKey,
parentTaskId: action.parentTaskId,
agentId: action.agentId,
runId: action.runId,
label: action.label,
task: action.task,
preferMetadata: action.preferMetadata,
notifyPolicy: action.notifyPolicy,
status: action.status,
startedAt: action.startedAt,
lastEventAt: action.lastEventAt,
progressSummary: action.progressSummary,
});
if (result.created) {
return {
created: true,
flow: toFlowView(result.flow),
task: toTaskView(result.task),
};
}
return {
found: result.found,
created: false,
reason: result.reason,
...(result.flow ? { flow: toFlowView(result.flow) } : {}),
};
}
}
throw new Error("Unsupported webhook action");
}
export function createTaskFlowWebhookRequestHandler(params: {
cfg: OpenClawConfig;
targetsByPath: Map<string, TaskFlowWebhookTarget[]>;
inFlightLimiter?: WebhookInFlightLimiter;
}): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
const rateLimiter = createFixedWindowRateLimiter({
windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs,
maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests,
maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys,
});
const inFlightLimiter =
params.inFlightLimiter ??
createWebhookInFlightLimiter({
maxInFlightPerKey: WEBHOOK_IN_FLIGHT_DEFAULTS.maxInFlightPerKey,
maxTrackedKeys: WEBHOOK_IN_FLIGHT_DEFAULTS.maxTrackedKeys,
});
const resolveTargetSecret = async (
target: TaskFlowWebhookTarget,
): Promise<string | undefined> => {
if (typeof target.secretInput === "string") {
return target.secretInput;
}
const resolved = await resolveConfiguredSecretInputString({
config: params.cfg,
env: process.env,
value: target.secretInput,
path: target.secretConfigPath,
});
return resolved.value;
};
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
return await withResolvedWebhookRequestPipeline({
req,
res,
targetsByPath: params.targetsByPath,
allowMethods: ["POST"],
requireJsonContentType: true,
rateLimiter,
rateLimitKey: (() => {
const clientIp =
resolveRequestClientIp(
req,
params.cfg.gateway?.trustedProxies,
params.cfg.gateway?.allowRealIpFallback === true,
) ??
req.socket.remoteAddress ??
"unknown";
return `${new URL(req.url ?? "/", "http://localhost").pathname}:${clientIp}`;
})(),
inFlightLimiter,
handle: async ({ targets }) => {
const presentedSecret = extractSharedSecret(req);
const target = await resolveWebhookTargetWithAuthOrReject({
targets,
res,
isMatch: async (candidate) => {
if (presentedSecret.length === 0) {
return false;
}
const resolvedSecret = await resolveTargetSecret(candidate);
return Boolean(resolvedSecret && safeEqualSecret(resolvedSecret, presentedSecret));
},
});
if (!target) {
return true;
}
const body = await readJsonWebhookBodyOrReject({
req,
res,
maxBytes: 256 * 1024,
timeoutMs: 15_000,
emptyObjectOnEmpty: false,
invalidJsonMessage: "invalid request body",
});
if (!body.ok) {
return true;
}
const parsed = webhookActionSchema.safeParse(body.value);
if (!parsed.success) {
writeJson(res, 400, {
ok: false,
code: "invalid_request",
error: formatZodError(parsed.error),
});
return true;
}
const result = await executeWebhookAction({
action: parsed.data,
target,
cfg: params.cfg,
});
const outcome = describeWebhookOutcome({
action: parsed.data,
result,
});
writeJson(
res,
outcome.statusCode,
outcome.statusCode < 400
? {
ok: true,
routeId: target.routeId,
...(outcome.code ? { code: outcome.code } : {}),
result,
}
: {
ok: false,
routeId: target.routeId,
code: outcome.code ?? "request_rejected",
error: outcome.error ?? "request rejected",
result,
},
);
return true;
},
});
};
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}