Skip to content

Commit 95cdc25

Browse files
G9PedroClawdious
andauthored
feat: add universal webhook gateway endpoints and CLI workflow (#41)
Introduce a provider-aware webhook gateway with signature verification and event normalization so external systems can safely feed workgraph automation through one endpoint model. Add lifecycle and operations commands for webhook sources to make setup, testing, and observability first-class from the CLI. Made-with: Cursor Co-authored-by: Clawdious <clawdious@agentmail.to>
1 parent cce47ad commit 95cdc25

File tree

6 files changed

+1566
-0
lines changed

6 files changed

+1566
-0
lines changed

packages/cli/src/cli.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { registerMcpCommands } from './cli/commands/mcp.js';
1313
import { registerSafetyCommands } from './cli/commands/safety.js';
1414
import { registerPortabilityCommands } from './cli/commands/portability.js';
1515
import { registerFederationCommands } from './cli/commands/federation.js';
16+
import { registerWebhookCommands } from './cli/commands/webhook.js';
1617
import { registerTriggerCommands } from './cli/commands/trigger.js';
1718
import {
1819
addWorkspaceOption,
@@ -2308,6 +2309,7 @@ registerCursorCommands(program, DEFAULT_ACTOR);
23082309
// ============================================================================
23092310

23102311
registerTriggerCommands(program, DEFAULT_ACTOR);
2312+
registerWebhookCommands(program, DEFAULT_ACTOR);
23112313

23122314
// ============================================================================
23132315
// conversation + plan-step
@@ -2456,6 +2458,7 @@ addWorkspaceOption(
24562458
console.log(`MCP endpoint: ${handle.url}`);
24572459
console.log(`Health: ${handle.healthUrl}`);
24582460
console.log(`Status API: ${handle.baseUrl}/api/status`);
2461+
console.log(`Webhook endpoint template: ${handle.webhookGatewayUrlTemplate}`);
24592462
await waitForShutdown(handle, {
24602463
onSignal: (signal) => {
24612464
console.error(`Received ${signal}; shutting down...`);
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { Command } from 'commander';
4+
import * as workgraph from '@versatly/workgraph-kernel';
5+
import {
6+
deleteWebhookGatewaySource,
7+
listWebhookGatewayLogs,
8+
listWebhookGatewaySources,
9+
registerWebhookGatewaySource,
10+
startWorkgraphServer,
11+
testWebhookGatewaySource,
12+
waitForShutdown,
13+
type WebhookGatewayProvider,
14+
} from '@versatly/workgraph-control-api';
15+
import {
16+
addWorkspaceOption,
17+
parsePortOption,
18+
resolveWorkspacePath,
19+
runCommand,
20+
wantsJson,
21+
} from '../core.js';
22+
23+
export function registerWebhookCommands(program: Command, defaultActor: string): void {
24+
const webhookCmd = program
25+
.command('webhook')
26+
.description('Universal webhook gateway management and operations');
27+
28+
addWorkspaceOption(
29+
webhookCmd
30+
.command('serve')
31+
.description('Serve HTTP endpoints for inbound webhook sources')
32+
.option('--port <port>', 'HTTP port (defaults to server config or 8787)')
33+
.option('--host <host>', 'Bind host (defaults to server config or 0.0.0.0)')
34+
.option('--token <token>', 'Optional bearer token for MCP + REST auth')
35+
.option('-a, --actor <name>', 'Default actor for gateway-triggered mutations')
36+
.option('--json', 'Emit structured JSON startup output'),
37+
).action(async (opts) => {
38+
const workspacePath = resolveWorkspacePath(opts);
39+
const serverConfig = workgraph.serverConfig.loadServerConfig(workspacePath);
40+
const port = opts.port !== undefined
41+
? parsePortOption(opts.port)
42+
: (serverConfig?.port ?? 8787);
43+
const host = opts.host
44+
? String(opts.host)
45+
: (serverConfig?.host ?? '0.0.0.0');
46+
const actor = opts.actor
47+
? String(opts.actor)
48+
: (serverConfig?.defaultActor ?? defaultActor);
49+
const bearerToken = opts.token
50+
? String(opts.token)
51+
: serverConfig?.bearerToken;
52+
53+
const handle = await startWorkgraphServer({
54+
workspacePath,
55+
host,
56+
port,
57+
bearerToken,
58+
defaultActor: actor,
59+
endpointPath: serverConfig?.endpointPath,
60+
});
61+
62+
const startupPayload = {
63+
serverUrl: handle.baseUrl,
64+
healthUrl: handle.healthUrl,
65+
mcpUrl: handle.url,
66+
webhookGatewayUrlTemplate: handle.webhookGatewayUrlTemplate,
67+
};
68+
if (wantsJson(opts)) {
69+
console.log(JSON.stringify({
70+
ok: true,
71+
data: startupPayload,
72+
}, null, 2));
73+
} else {
74+
console.log(`Server URL: ${handle.baseUrl}`);
75+
console.log(`Webhook endpoint template: ${handle.webhookGatewayUrlTemplate}`);
76+
console.log(`Health: ${handle.healthUrl}`);
77+
console.log(`MCP endpoint: ${handle.url}`);
78+
}
79+
80+
await waitForShutdown(handle, {
81+
onSignal: (signal) => {
82+
if (!wantsJson(opts)) {
83+
console.error(`Received ${signal}; shutting down webhook gateway...`);
84+
}
85+
},
86+
onClosed: () => {
87+
if (!wantsJson(opts)) {
88+
console.error('Webhook gateway stopped.');
89+
}
90+
},
91+
});
92+
});
93+
94+
addWorkspaceOption(
95+
webhookCmd
96+
.command('register <key>')
97+
.description('Register a webhook source endpoint')
98+
.requiredOption('--provider <provider>', 'github|linear|slack|generic')
99+
.option('--secret <secret>', 'HMAC secret for signature verification')
100+
.option('-a, --actor <name>', 'Actor used for accepted webhook events', defaultActor)
101+
.option('--event-prefix <prefix>', 'Event namespace suffix (default: provider)')
102+
.option('--disabled', 'Register source as disabled')
103+
.option('--json', 'Emit structured JSON output'),
104+
).action((key, opts) =>
105+
runCommand(
106+
opts,
107+
() => {
108+
const workspacePath = resolveWorkspacePath(opts);
109+
return {
110+
source: registerWebhookGatewaySource(workspacePath, {
111+
key,
112+
provider: parseWebhookProvider(opts.provider),
113+
secret: opts.secret,
114+
actor: opts.actor,
115+
eventPrefix: opts.eventPrefix,
116+
enabled: !opts.disabled,
117+
}),
118+
};
119+
},
120+
(result) => [
121+
`Registered webhook source: ${result.source.key}`,
122+
`Provider: ${result.source.provider}`,
123+
`Enabled: ${result.source.enabled}`,
124+
`Secret configured: ${result.source.hasSecret}`,
125+
],
126+
),
127+
);
128+
129+
addWorkspaceOption(
130+
webhookCmd
131+
.command('list')
132+
.description('List registered webhook sources')
133+
.option('--provider <provider>', 'Filter by provider github|linear|slack|generic')
134+
.option('--json', 'Emit structured JSON output'),
135+
).action((opts) =>
136+
runCommand(
137+
opts,
138+
() => {
139+
const workspacePath = resolveWorkspacePath(opts);
140+
const provider = opts.provider ? parseWebhookProvider(opts.provider) : undefined;
141+
const sources = listWebhookGatewaySources(workspacePath)
142+
.filter((source) => (provider ? source.provider === provider : true));
143+
return {
144+
count: sources.length,
145+
sources,
146+
};
147+
},
148+
(result) => {
149+
if (result.sources.length === 0) return ['No webhook sources found.'];
150+
return [
151+
...result.sources.map((source) =>
152+
`${source.key} provider=${source.provider} enabled=${source.enabled} secret=${source.hasSecret}`),
153+
`${result.count} source(s)`,
154+
];
155+
},
156+
),
157+
);
158+
159+
addWorkspaceOption(
160+
webhookCmd
161+
.command('delete <keyOrId>')
162+
.description('Delete a registered webhook source')
163+
.option('--json', 'Emit structured JSON output'),
164+
).action((keyOrId, opts) =>
165+
runCommand(
166+
opts,
167+
() => {
168+
const workspacePath = resolveWorkspacePath(opts);
169+
const deleted = deleteWebhookGatewaySource(workspacePath, keyOrId);
170+
if (!deleted) {
171+
throw new Error(`Webhook source not found: ${keyOrId}`);
172+
}
173+
return {
174+
deleted: keyOrId,
175+
};
176+
},
177+
(result) => [`Deleted webhook source: ${result.deleted}`],
178+
),
179+
);
180+
181+
addWorkspaceOption(
182+
webhookCmd
183+
.command('test <sourceKey>')
184+
.description('Emit a synthetic webhook event for one source')
185+
.option('--event <eventType>', 'Event type (default: webhook.<provider>.test)')
186+
.option('--payload <json>', 'Payload JSON string')
187+
.option('--payload-file <path>', 'Payload JSON file path')
188+
.option('--delivery-id <id>', 'Optional explicit delivery id')
189+
.option('--json', 'Emit structured JSON output'),
190+
).action((sourceKey, opts) =>
191+
runCommand(
192+
opts,
193+
() => {
194+
const workspacePath = resolveWorkspacePath(opts);
195+
return testWebhookGatewaySource(workspacePath, {
196+
sourceKey,
197+
eventType: opts.event,
198+
payload: parseTestPayload(opts.payload, opts.payloadFile),
199+
deliveryId: opts.deliveryId,
200+
});
201+
},
202+
(result) => [
203+
`Sent synthetic webhook: ${result.source.key}`,
204+
`Event: ${result.eventType}`,
205+
`Delivery: ${result.deliveryId}`,
206+
],
207+
),
208+
);
209+
210+
addWorkspaceOption(
211+
webhookCmd
212+
.command('log')
213+
.description('Read recent webhook gateway delivery logs')
214+
.option('--source <key>', 'Filter by source key')
215+
.option('--limit <n>', 'Limit entries (default: 50)', '50')
216+
.option('--json', 'Emit structured JSON output'),
217+
).action((opts) =>
218+
runCommand(
219+
opts,
220+
() => {
221+
const workspacePath = resolveWorkspacePath(opts);
222+
const limit = Number.parseInt(String(opts.limit), 10);
223+
const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 50;
224+
const logs = listWebhookGatewayLogs(workspacePath, {
225+
limit: safeLimit,
226+
sourceKey: opts.source,
227+
});
228+
return {
229+
count: logs.length,
230+
logs,
231+
};
232+
},
233+
(result) => {
234+
if (result.logs.length === 0) return ['No webhook logs found.'];
235+
return [
236+
...result.logs.map((entry) =>
237+
`${entry.ts} [${entry.status}] source=${entry.sourceKey} event=${entry.eventType} code=${entry.statusCode}`),
238+
`${result.count} log entr${result.count === 1 ? 'y' : 'ies'}`,
239+
];
240+
},
241+
),
242+
);
243+
}
244+
245+
function parseWebhookProvider(value: unknown): WebhookGatewayProvider {
246+
const normalized = String(value ?? '').trim().toLowerCase();
247+
if (
248+
normalized === 'github'
249+
|| normalized === 'linear'
250+
|| normalized === 'slack'
251+
|| normalized === 'generic'
252+
) {
253+
return normalized;
254+
}
255+
throw new Error(`Invalid webhook provider "${String(value)}". Expected github|linear|slack|generic.`);
256+
}
257+
258+
function parseTestPayload(rawPayload: unknown, payloadFile: unknown): unknown {
259+
const payloadText = typeof rawPayload === 'string'
260+
? rawPayload.trim()
261+
: '';
262+
if (payloadText) {
263+
return parseJsonPayload(payloadText, '--payload');
264+
}
265+
const payloadFilePath = typeof payloadFile === 'string'
266+
? payloadFile.trim()
267+
: '';
268+
if (payloadFilePath) {
269+
const absolutePath = path.resolve(payloadFilePath);
270+
const fileText = fs.readFileSync(absolutePath, 'utf-8');
271+
return parseJsonPayload(fileText, '--payload-file');
272+
}
273+
return undefined;
274+
}
275+
276+
function parseJsonPayload(text: string, option: string): unknown {
277+
try {
278+
return JSON.parse(text) as unknown;
279+
} catch {
280+
throw new Error(`Invalid ${option} JSON payload.`);
281+
}
282+
}

packages/control-api/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './dispatch.js';
22
export * from './server.js';
3+
export * from './webhook-gateway.js';

packages/control-api/src/server.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
listWebhooks,
3232
registerWebhook,
3333
} from './server-webhooks.js';
34+
import { registerWebhookGatewayEndpoint } from './webhook-gateway.js';
3435

3536
const ledger = ledgerModule;
3637
const auth = authModule;
@@ -72,6 +73,7 @@ export interface WorkgraphServerHandle {
7273
baseUrl: string;
7374
healthUrl: string;
7475
url: string;
76+
webhookGatewayUrlTemplate: string;
7577
close: () => Promise<void>;
7678
workspacePath: string;
7779
workspaceInitialized: boolean;
@@ -134,6 +136,7 @@ export async function startWorkgraphServer(options: WorkgraphServerOptions): Pro
134136
endpointPath,
135137
bearerToken: options.bearerToken,
136138
onApp: ({ app, bearerAuthMiddleware }) => {
139+
registerWebhookGatewayEndpoint(app, workspacePath);
137140
app.use('/api', bearerAuthMiddleware);
138141
app.use('/api', (req: any, _res: any, next: () => void) => {
139142
auth.runWithAuthContext(buildRequestAuthContext(req), () => next());
@@ -148,6 +151,7 @@ export async function startWorkgraphServer(options: WorkgraphServerOptions): Pro
148151

149152
return {
150153
...handle,
154+
webhookGatewayUrlTemplate: `${handle.baseUrl}/webhook-gateway/{sourceKey}`,
151155
close: async () => {
152156
unsubscribeWebhookDispatch();
153157
await handle.close();
@@ -212,6 +216,7 @@ export async function runWorkgraphServerFromEnv(): Promise<void> {
212216
endpointPath: handle.endpointPath,
213217
mcpUrl: handle.url,
214218
healthUrl: handle.healthUrl,
219+
webhookGatewayUrlTemplate: handle.webhookGatewayUrlTemplate,
215220
});
216221

217222
await waitForShutdown(handle, {

0 commit comments

Comments
 (0)