From 033ec1821930e26acaef0eb30cbe81990932bbac Mon Sep 17 00:00:00 2001 From: Clawdious Date: Fri, 6 Mar 2026 14:25:09 -0500 Subject: [PATCH] feat: add universal webhook gateway endpoints and CLI workflow 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 --- packages/cli/src/cli.ts | 3 + packages/cli/src/cli/commands/webhook.ts | 282 +++++ packages/control-api/src/index.ts | 1 + packages/control-api/src/server.ts | 5 + .../control-api/src/webhook-gateway.test.ts | 247 ++++ packages/control-api/src/webhook-gateway.ts | 1028 +++++++++++++++++ 6 files changed, 1566 insertions(+) create mode 100644 packages/cli/src/cli/commands/webhook.ts create mode 100644 packages/control-api/src/webhook-gateway.test.ts create mode 100644 packages/control-api/src/webhook-gateway.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 85ba002..a230867 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -13,6 +13,7 @@ import { registerMcpCommands } from './cli/commands/mcp.js'; import { registerSafetyCommands } from './cli/commands/safety.js'; import { registerPortabilityCommands } from './cli/commands/portability.js'; import { registerFederationCommands } from './cli/commands/federation.js'; +import { registerWebhookCommands } from './cli/commands/webhook.js'; import { registerTriggerCommands } from './cli/commands/trigger.js'; import { addWorkspaceOption, @@ -2215,6 +2216,7 @@ registerCursorCommands(program, DEFAULT_ACTOR); // ============================================================================ registerTriggerCommands(program, DEFAULT_ACTOR); +registerWebhookCommands(program, DEFAULT_ACTOR); // ============================================================================ // conversation + plan-step @@ -2363,6 +2365,7 @@ addWorkspaceOption( console.log(`MCP endpoint: ${handle.url}`); console.log(`Health: ${handle.healthUrl}`); console.log(`Status API: ${handle.baseUrl}/api/status`); + console.log(`Webhook endpoint template: ${handle.webhookGatewayUrlTemplate}`); await waitForShutdown(handle, { onSignal: (signal) => { console.error(`Received ${signal}; shutting down...`); diff --git a/packages/cli/src/cli/commands/webhook.ts b/packages/cli/src/cli/commands/webhook.ts new file mode 100644 index 0000000..22bef5a --- /dev/null +++ b/packages/cli/src/cli/commands/webhook.ts @@ -0,0 +1,282 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { Command } from 'commander'; +import * as workgraph from '@versatly/workgraph-kernel'; +import { + deleteWebhookGatewaySource, + listWebhookGatewayLogs, + listWebhookGatewaySources, + registerWebhookGatewaySource, + startWorkgraphServer, + testWebhookGatewaySource, + waitForShutdown, + type WebhookGatewayProvider, +} from '@versatly/workgraph-control-api'; +import { + addWorkspaceOption, + parsePortOption, + resolveWorkspacePath, + runCommand, + wantsJson, +} from '../core.js'; + +export function registerWebhookCommands(program: Command, defaultActor: string): void { + const webhookCmd = program + .command('webhook') + .description('Universal webhook gateway management and operations'); + + addWorkspaceOption( + webhookCmd + .command('serve') + .description('Serve HTTP endpoints for inbound webhook sources') + .option('--port ', 'HTTP port (defaults to server config or 8787)') + .option('--host ', 'Bind host (defaults to server config or 0.0.0.0)') + .option('--token ', 'Optional bearer token for MCP + REST auth') + .option('-a, --actor ', 'Default actor for gateway-triggered mutations') + .option('--json', 'Emit structured JSON startup output'), + ).action(async (opts) => { + const workspacePath = resolveWorkspacePath(opts); + const serverConfig = workgraph.serverConfig.loadServerConfig(workspacePath); + const port = opts.port !== undefined + ? parsePortOption(opts.port) + : (serverConfig?.port ?? 8787); + const host = opts.host + ? String(opts.host) + : (serverConfig?.host ?? '0.0.0.0'); + const actor = opts.actor + ? String(opts.actor) + : (serverConfig?.defaultActor ?? defaultActor); + const bearerToken = opts.token + ? String(opts.token) + : serverConfig?.bearerToken; + + const handle = await startWorkgraphServer({ + workspacePath, + host, + port, + bearerToken, + defaultActor: actor, + endpointPath: serverConfig?.endpointPath, + }); + + const startupPayload = { + serverUrl: handle.baseUrl, + healthUrl: handle.healthUrl, + mcpUrl: handle.url, + webhookGatewayUrlTemplate: handle.webhookGatewayUrlTemplate, + }; + if (wantsJson(opts)) { + console.log(JSON.stringify({ + ok: true, + data: startupPayload, + }, null, 2)); + } else { + console.log(`Server URL: ${handle.baseUrl}`); + console.log(`Webhook endpoint template: ${handle.webhookGatewayUrlTemplate}`); + console.log(`Health: ${handle.healthUrl}`); + console.log(`MCP endpoint: ${handle.url}`); + } + + await waitForShutdown(handle, { + onSignal: (signal) => { + if (!wantsJson(opts)) { + console.error(`Received ${signal}; shutting down webhook gateway...`); + } + }, + onClosed: () => { + if (!wantsJson(opts)) { + console.error('Webhook gateway stopped.'); + } + }, + }); + }); + + addWorkspaceOption( + webhookCmd + .command('register ') + .description('Register a webhook source endpoint') + .requiredOption('--provider ', 'github|linear|slack|generic') + .option('--secret ', 'HMAC secret for signature verification') + .option('-a, --actor ', 'Actor used for accepted webhook events', defaultActor) + .option('--event-prefix ', 'Event namespace suffix (default: provider)') + .option('--disabled', 'Register source as disabled') + .option('--json', 'Emit structured JSON output'), + ).action((key, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + return { + source: registerWebhookGatewaySource(workspacePath, { + key, + provider: parseWebhookProvider(opts.provider), + secret: opts.secret, + actor: opts.actor, + eventPrefix: opts.eventPrefix, + enabled: !opts.disabled, + }), + }; + }, + (result) => [ + `Registered webhook source: ${result.source.key}`, + `Provider: ${result.source.provider}`, + `Enabled: ${result.source.enabled}`, + `Secret configured: ${result.source.hasSecret}`, + ], + ), + ); + + addWorkspaceOption( + webhookCmd + .command('list') + .description('List registered webhook sources') + .option('--provider ', 'Filter by provider github|linear|slack|generic') + .option('--json', 'Emit structured JSON output'), + ).action((opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + const provider = opts.provider ? parseWebhookProvider(opts.provider) : undefined; + const sources = listWebhookGatewaySources(workspacePath) + .filter((source) => (provider ? source.provider === provider : true)); + return { + count: sources.length, + sources, + }; + }, + (result) => { + if (result.sources.length === 0) return ['No webhook sources found.']; + return [ + ...result.sources.map((source) => + `${source.key} provider=${source.provider} enabled=${source.enabled} secret=${source.hasSecret}`), + `${result.count} source(s)`, + ]; + }, + ), + ); + + addWorkspaceOption( + webhookCmd + .command('delete ') + .description('Delete a registered webhook source') + .option('--json', 'Emit structured JSON output'), + ).action((keyOrId, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + const deleted = deleteWebhookGatewaySource(workspacePath, keyOrId); + if (!deleted) { + throw new Error(`Webhook source not found: ${keyOrId}`); + } + return { + deleted: keyOrId, + }; + }, + (result) => [`Deleted webhook source: ${result.deleted}`], + ), + ); + + addWorkspaceOption( + webhookCmd + .command('test ') + .description('Emit a synthetic webhook event for one source') + .option('--event ', 'Event type (default: webhook..test)') + .option('--payload ', 'Payload JSON string') + .option('--payload-file ', 'Payload JSON file path') + .option('--delivery-id ', 'Optional explicit delivery id') + .option('--json', 'Emit structured JSON output'), + ).action((sourceKey, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + return testWebhookGatewaySource(workspacePath, { + sourceKey, + eventType: opts.event, + payload: parseTestPayload(opts.payload, opts.payloadFile), + deliveryId: opts.deliveryId, + }); + }, + (result) => [ + `Sent synthetic webhook: ${result.source.key}`, + `Event: ${result.eventType}`, + `Delivery: ${result.deliveryId}`, + ], + ), + ); + + addWorkspaceOption( + webhookCmd + .command('log') + .description('Read recent webhook gateway delivery logs') + .option('--source ', 'Filter by source key') + .option('--limit ', 'Limit entries (default: 50)', '50') + .option('--json', 'Emit structured JSON output'), + ).action((opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + const limit = Number.parseInt(String(opts.limit), 10); + const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 50; + const logs = listWebhookGatewayLogs(workspacePath, { + limit: safeLimit, + sourceKey: opts.source, + }); + return { + count: logs.length, + logs, + }; + }, + (result) => { + if (result.logs.length === 0) return ['No webhook logs found.']; + return [ + ...result.logs.map((entry) => + `${entry.ts} [${entry.status}] source=${entry.sourceKey} event=${entry.eventType} code=${entry.statusCode}`), + `${result.count} log entr${result.count === 1 ? 'y' : 'ies'}`, + ]; + }, + ), + ); +} + +function parseWebhookProvider(value: unknown): WebhookGatewayProvider { + const normalized = String(value ?? '').trim().toLowerCase(); + if ( + normalized === 'github' + || normalized === 'linear' + || normalized === 'slack' + || normalized === 'generic' + ) { + return normalized; + } + throw new Error(`Invalid webhook provider "${String(value)}". Expected github|linear|slack|generic.`); +} + +function parseTestPayload(rawPayload: unknown, payloadFile: unknown): unknown { + const payloadText = typeof rawPayload === 'string' + ? rawPayload.trim() + : ''; + if (payloadText) { + return parseJsonPayload(payloadText, '--payload'); + } + const payloadFilePath = typeof payloadFile === 'string' + ? payloadFile.trim() + : ''; + if (payloadFilePath) { + const absolutePath = path.resolve(payloadFilePath); + const fileText = fs.readFileSync(absolutePath, 'utf-8'); + return parseJsonPayload(fileText, '--payload-file'); + } + return undefined; +} + +function parseJsonPayload(text: string, option: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch { + throw new Error(`Invalid ${option} JSON payload.`); + } +} diff --git a/packages/control-api/src/index.ts b/packages/control-api/src/index.ts index 17bf9f9..079bdfc 100644 --- a/packages/control-api/src/index.ts +++ b/packages/control-api/src/index.ts @@ -1,2 +1,3 @@ export * from './dispatch.js'; export * from './server.js'; +export * from './webhook-gateway.js'; diff --git a/packages/control-api/src/server.ts b/packages/control-api/src/server.ts index dd7b263..4ca2bd8 100644 --- a/packages/control-api/src/server.ts +++ b/packages/control-api/src/server.ts @@ -31,6 +31,7 @@ import { listWebhooks, registerWebhook, } from './server-webhooks.js'; +import { registerWebhookGatewayEndpoint } from './webhook-gateway.js'; const ledger = ledgerModule; const auth = authModule; @@ -72,6 +73,7 @@ export interface WorkgraphServerHandle { baseUrl: string; healthUrl: string; url: string; + webhookGatewayUrlTemplate: string; close: () => Promise; workspacePath: string; workspaceInitialized: boolean; @@ -134,6 +136,7 @@ export async function startWorkgraphServer(options: WorkgraphServerOptions): Pro endpointPath, bearerToken: options.bearerToken, onApp: ({ app, bearerAuthMiddleware }) => { + registerWebhookGatewayEndpoint(app, workspacePath); app.use('/api', bearerAuthMiddleware); app.use('/api', (req: any, _res: any, next: () => void) => { auth.runWithAuthContext(buildRequestAuthContext(req), () => next()); @@ -148,6 +151,7 @@ export async function startWorkgraphServer(options: WorkgraphServerOptions): Pro return { ...handle, + webhookGatewayUrlTemplate: `${handle.baseUrl}/webhook-gateway/{sourceKey}`, close: async () => { unsubscribeWebhookDispatch(); await handle.close(); @@ -212,6 +216,7 @@ export async function runWorkgraphServerFromEnv(): Promise { endpointPath: handle.endpointPath, mcpUrl: handle.url, healthUrl: handle.healthUrl, + webhookGatewayUrlTemplate: handle.webhookGatewayUrlTemplate, }); await waitForShutdown(handle, { diff --git a/packages/control-api/src/webhook-gateway.test.ts b/packages/control-api/src/webhook-gateway.test.ts new file mode 100644 index 0000000..b0da043 --- /dev/null +++ b/packages/control-api/src/webhook-gateway.test.ts @@ -0,0 +1,247 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ledger as ledgerModule, workspace as workspaceModule } from '@versatly/workgraph-kernel'; +import { startWorkgraphServer } from './server.js'; +import { + deleteWebhookGatewaySource, + listWebhookGatewayLogs, + listWebhookGatewaySources, + registerWebhookGatewaySource, + testWebhookGatewaySource, +} from './webhook-gateway.js'; + +const ledger = ledgerModule; +const workspace = workspaceModule; + +let workspacePath: string; + +beforeEach(() => { + workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-webhook-gateway-')); + workspace.initWorkspace(workspacePath, { + createBases: false, + createReadme: false, + }); +}); + +afterEach(() => { + fs.rmSync(workspacePath, { recursive: true, force: true }); +}); + +describe('webhook gateway source lifecycle', () => { + it('registers, lists, tests, and deletes sources', () => { + const created = registerWebhookGatewaySource(workspacePath, { + key: 'github-main', + provider: 'github', + secret: 'github-secret', + actor: 'bot-github', + }); + expect(created.key).toBe('github-main'); + expect(created.provider).toBe('github'); + expect(created.hasSecret).toBe(true); + + const listed = listWebhookGatewaySources(workspacePath); + expect(listed).toHaveLength(1); + expect(listed[0].key).toBe('github-main'); + + const tested = testWebhookGatewaySource(workspacePath, { + sourceKey: 'github-main', + eventType: 'webhook.github.test.ping', + payload: { + ping: true, + }, + }); + expect(tested.eventType).toBe('webhook.github.test.ping'); + expect(tested.log.status).toBe('accepted'); + + const recent = ledger.recent(workspacePath, 5); + const gatewayLedgerEntry = recent.find((entry) => entry.target.includes('.workgraph/webhook-gateway/github-main/')); + expect(gatewayLedgerEntry).toBeDefined(); + expect(gatewayLedgerEntry?.type).toBe('event'); + + const logs = listWebhookGatewayLogs(workspacePath, { limit: 10 }); + expect(logs.length).toBeGreaterThan(0); + expect(logs[0]?.sourceKey).toBe('github-main'); + + const deleted = deleteWebhookGatewaySource(workspacePath, 'github-main'); + expect(deleted).toBe(true); + expect(listWebhookGatewaySources(workspacePath)).toHaveLength(0); + }); +}); + +describe('webhook gateway HTTP endpoint', () => { + it('accepts valid GitHub signatures and emits event ledger entries', async () => { + registerWebhookGatewaySource(workspacePath, { + key: 'github-main', + provider: 'github', + secret: 'github-secret', + actor: 'github-bot', + }); + + const handle = await startWorkgraphServer({ + workspacePath, + host: '127.0.0.1', + port: 0, + }); + try { + const payload = JSON.stringify({ + action: 'opened', + pull_request: { + number: 42, + }, + }); + const signature = signGithub(payload, 'github-secret'); + const response = await fetch(`${handle.baseUrl}/webhook-gateway/github-main`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-github-event': 'pull_request', + 'x-github-delivery': 'delivery-123', + 'x-hub-signature-256': signature, + }, + body: payload, + }); + const body = await response.json() as Record; + + expect(response.status).toBe(202); + expect(body.accepted).toBe(true); + expect(body.eventType).toBe('webhook.github.pull_request'); + + const recent = ledger.recent(workspacePath, 10); + const entry = recent.find((item) => item.target.includes('.workgraph/webhook-gateway/github-main/delivery-123')); + expect(entry).toBeDefined(); + expect(entry?.data?.event_type).toBe('webhook.github.pull_request'); + + const logs = listWebhookGatewayLogs(workspacePath, { limit: 1 }); + expect(logs[0]?.status).toBe('accepted'); + expect(logs[0]?.signatureVerified).toBe(true); + } finally { + await handle.close(); + } + }); + + it('rejects invalid GitHub signatures', async () => { + registerWebhookGatewaySource(workspacePath, { + key: 'github-main', + provider: 'github', + secret: 'github-secret', + }); + + const handle = await startWorkgraphServer({ + workspacePath, + host: '127.0.0.1', + port: 0, + }); + try { + const payload = JSON.stringify({ + action: 'created', + }); + const response = await fetch(`${handle.baseUrl}/webhook-gateway/github-main`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-github-event': 'issue_comment', + 'x-hub-signature-256': 'sha256=deadbeef', + }, + body: payload, + }); + const body = await response.json() as Record; + expect(response.status).toBe(401); + expect(String(body.error)).toContain('GitHub signature verification failed'); + + const logs = listWebhookGatewayLogs(workspacePath, { limit: 1 }); + expect(logs[0]?.status).toBe('rejected'); + expect(logs[0]?.statusCode).toBe(401); + } finally { + await handle.close(); + } + }); + + it('rejects stale Slack timestamps even with valid signature', async () => { + registerWebhookGatewaySource(workspacePath, { + key: 'slack-main', + provider: 'slack', + secret: 'slack-secret', + }); + + const handle = await startWorkgraphServer({ + workspacePath, + host: '127.0.0.1', + port: 0, + }); + try { + const payload = JSON.stringify({ + type: 'event_callback', + event: { + type: 'message', + }, + }); + const staleTimestamp = String(Math.floor(Date.now() / 1_000) - 60 * 10); + const signature = signSlack(payload, 'slack-secret', staleTimestamp); + const response = await fetch(`${handle.baseUrl}/webhook-gateway/slack-main`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-slack-request-timestamp': staleTimestamp, + 'x-slack-signature': signature, + }, + body: payload, + }); + const body = await response.json() as Record; + expect(response.status).toBe(401); + expect(String(body.error)).toContain('outside the accepted time window'); + } finally { + await handle.close(); + } + }); + + it('accepts unsigned generic source when no secret is configured', async () => { + registerWebhookGatewaySource(workspacePath, { + key: 'generic-main', + provider: 'generic', + }); + + const handle = await startWorkgraphServer({ + workspacePath, + host: '127.0.0.1', + port: 0, + }); + try { + const payload = JSON.stringify({ + type: 'deploy.completed', + env: 'prod', + }); + const response = await fetch(`${handle.baseUrl}/webhook-gateway/generic-main`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-webhook-event': 'deploy.completed', + 'x-request-id': 'req-123', + }, + body: payload, + }); + const body = await response.json() as Record; + expect(response.status).toBe(202); + expect(body.eventType).toBe('webhook.generic.deploy.completed'); + + const logs = listWebhookGatewayLogs(workspacePath, { limit: 1 }); + expect(logs[0]?.status).toBe('accepted'); + expect(logs[0]?.signatureVerified).toBe(false); + } finally { + await handle.close(); + } + }); +}); + +function signGithub(rawBody: string, secret: string): string { + const digest = crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); + return `sha256=${digest}`; +} + +function signSlack(rawBody: string, secret: string, timestamp: string): string { + const base = `v0:${timestamp}:${rawBody}`; + const digest = crypto.createHmac('sha256', secret).update(base).digest('hex'); + return `v0=${digest}`; +} diff --git a/packages/control-api/src/webhook-gateway.ts b/packages/control-api/src/webhook-gateway.ts new file mode 100644 index 0000000..82b7990 --- /dev/null +++ b/packages/control-api/src/webhook-gateway.ts @@ -0,0 +1,1028 @@ +import crypto, { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { ledger as ledgerModule } from '@versatly/workgraph-kernel'; + +const ledger = ledgerModule; + +const WEBHOOK_GATEWAY_STORE_PATH = '.workgraph/webhook-gateway-sources.json'; +const WEBHOOK_GATEWAY_LOG_PATH = '.workgraph/webhook-gateway.log.jsonl'; +const WEBHOOK_GATEWAY_STORE_VERSION = 1; +const DEFAULT_LOG_LIMIT = 50; +const MAX_LOG_LIMIT = 1_000; +const MAX_WEBHOOK_BODY_BYTES = 2 * 1024 * 1024; +const SLACK_SIGNATURE_MAX_AGE_SECONDS = 60 * 5; + +export type WebhookGatewayProvider = 'github' | 'linear' | 'slack' | 'generic'; +type LogStatus = 'accepted' | 'rejected'; + +interface WebhookGatewayStoreFile { + version: number; + sources: StoredWebhookGatewaySource[]; +} + +interface StoredWebhookGatewaySource { + id: string; + key: string; + provider: WebhookGatewayProvider; + createdAt: string; + enabled: boolean; + secret?: string; + actor?: string; + eventPrefix?: string; +} + +interface SignatureVerificationResult { + ok: boolean; + verified: boolean; + message: string; +} + +export interface RegisterWebhookGatewaySourceInput { + key: string; + provider: WebhookGatewayProvider; + secret?: string; + actor?: string; + eventPrefix?: string; + enabled?: boolean; +} + +export interface WebhookGatewaySourceView { + id: string; + key: string; + provider: WebhookGatewayProvider; + createdAt: string; + enabled: boolean; + hasSecret: boolean; + actor?: string; + eventPrefix?: string; +} + +export interface WebhookGatewayLogEntry { + id: string; + ts: string; + sourceKey: string; + provider: WebhookGatewayProvider; + eventType: string; + actor: string; + status: LogStatus; + statusCode: number; + signatureVerified: boolean; + message: string; + deliveryId?: string; + payloadDigest: string; +} + +export interface TestWebhookGatewaySourceInput { + sourceKey: string; + eventType?: string; + payload?: unknown; + deliveryId?: string; +} + +export interface TestWebhookGatewaySourceResult { + eventType: string; + deliveryId: string; + source: WebhookGatewaySourceView; + log: WebhookGatewayLogEntry; +} + +interface AdaptedWebhookPayload { + eventType: string; + deliveryId: string; + payload: unknown; +} + +export function registerWebhookGatewaySource( + workspacePath: string, + input: RegisterWebhookGatewaySourceInput, +): WebhookGatewaySourceView { + const key = normalizeSourceKey(input.key); + const provider = normalizeProvider(input.provider); + if (!provider) { + throw new Error(`Invalid webhook gateway provider "${String(input.provider)}". Expected github|linear|slack|generic.`); + } + const secret = readOptionalString(input.secret); + const actor = readOptionalString(input.actor); + const eventPrefix = readOptionalString(input.eventPrefix); + const enabled = input.enabled !== false; + + const store = readWebhookGatewayStore(workspacePath); + const existing = store.sources.find((source) => source.key === key); + if (existing) { + throw new Error(`Webhook gateway source already exists: ${key}`); + } + + const source: StoredWebhookGatewaySource = { + id: randomUUID(), + key, + provider, + createdAt: new Date().toISOString(), + enabled, + ...(secret ? { secret } : {}), + ...(actor ? { actor } : {}), + ...(eventPrefix ? { eventPrefix } : {}), + }; + store.sources.push(source); + writeWebhookGatewayStore(workspacePath, store); + return toWebhookGatewaySourceView(source); +} + +export function listWebhookGatewaySources(workspacePath: string): WebhookGatewaySourceView[] { + const store = readWebhookGatewayStore(workspacePath); + return store.sources + .slice() + .sort((left, right) => left.key.localeCompare(right.key)) + .map(toWebhookGatewaySourceView); +} + +export function deleteWebhookGatewaySource(workspacePath: string, keyOrId: string): boolean { + const normalized = String(keyOrId ?? '').trim(); + if (!normalized) return false; + + const store = readWebhookGatewayStore(workspacePath); + const before = store.sources.length; + store.sources = store.sources.filter((source) => source.key !== normalized && source.id !== normalized); + if (before === store.sources.length) return false; + writeWebhookGatewayStore(workspacePath, store); + return true; +} + +export function listWebhookGatewayLogs( + workspacePath: string, + options: { + limit?: number; + sourceKey?: string; + } = {}, +): WebhookGatewayLogEntry[] { + const filePath = webhookGatewayLogPath(workspacePath); + if (!fs.existsSync(filePath)) return []; + const limit = normalizeLogLimit(options.limit); + const sourceKey = readOptionalString(options.sourceKey); + + const lines = fs.readFileSync(filePath, 'utf-8') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + const parsed: WebhookGatewayLogEntry[] = []; + for (let idx = lines.length - 1; idx >= 0; idx -= 1) { + const line = lines[idx]; + let candidate: unknown; + try { + candidate = JSON.parse(line) as unknown; + } catch { + continue; + } + const log = sanitizeWebhookGatewayLogEntry(candidate); + if (!log) continue; + if (sourceKey && log.sourceKey !== sourceKey) continue; + parsed.push(log); + if (parsed.length >= limit) break; + } + return parsed; +} + +export function testWebhookGatewaySource( + workspacePath: string, + input: TestWebhookGatewaySourceInput, +): TestWebhookGatewaySourceResult { + const source = resolveSourceByKeyOrId(workspacePath, input.sourceKey); + if (!source) { + throw new Error(`Webhook gateway source not found: ${input.sourceKey}`); + } + const now = new Date().toISOString(); + const deliveryId = normalizeDeliveryId(input.deliveryId) ?? `test-${Date.now()}`; + const eventType = normalizeEventType( + input.eventType + ?? `webhook.${source.eventPrefix ?? source.provider}.test`, + ); + const payload = input.payload ?? { + source: source.key, + provider: source.provider, + mode: 'test', + ts: now, + }; + const payloadText = stringifyPayload(payload); + const payloadDigest = sha256Hex(payloadText); + const actor = source.actor ?? `webhook:${source.key}`; + appendWebhookGatewayLedgerEvent(workspacePath, source, { + eventType, + deliveryId, + payload, + payloadDigest, + actor, + }); + + const log: WebhookGatewayLogEntry = { + id: randomUUID(), + ts: now, + sourceKey: source.key, + provider: source.provider, + eventType, + actor, + status: 'accepted', + statusCode: 202, + signatureVerified: false, + message: 'Synthetic webhook gateway test event accepted.', + deliveryId, + payloadDigest, + }; + appendWebhookGatewayLog(workspacePath, log); + return { + eventType, + deliveryId, + source: toWebhookGatewaySourceView(source), + log, + }; +} + +export function registerWebhookGatewayEndpoint(app: any, workspacePath: string): void { + app.post('/webhook-gateway/:sourceKey', async (req: any, res: any) => { + const sourceKey = readOptionalString(req.params?.sourceKey); + if (!sourceKey) { + writeWebhookGatewayHttpResponse(res, 400, { + ok: false, + error: 'Webhook source key is required.', + }); + return; + } + + try { + const source = resolveSourceByKeyOrId(workspacePath, sourceKey); + if (!source) { + const log = createRejectedGatewayLog({ + sourceKey, + provider: 'generic', + eventType: 'webhook.unknown', + actor: `webhook:${sourceKey}`, + statusCode: 404, + signatureVerified: false, + message: `Webhook gateway source not found: ${sourceKey}`, + payloadDigest: sha256Hex(''), + }); + appendWebhookGatewayLog(workspacePath, log); + writeWebhookGatewayHttpResponse(res, 404, { + ok: false, + error: `Webhook gateway source not found: ${sourceKey}`, + }); + return; + } + if (!source.enabled) { + const log = createRejectedGatewayLog({ + sourceKey: source.key, + provider: source.provider, + eventType: `webhook.${source.eventPrefix ?? source.provider}.disabled`, + actor: source.actor ?? `webhook:${source.key}`, + statusCode: 403, + signatureVerified: false, + message: `Webhook gateway source is disabled: ${source.key}`, + payloadDigest: sha256Hex(''), + }); + appendWebhookGatewayLog(workspacePath, log); + writeWebhookGatewayHttpResponse(res, 403, { + ok: false, + error: `Webhook gateway source is disabled: ${source.key}`, + }); + return; + } + + const body = await resolveWebhookBody(req); + const verification = verifyWebhookSignature(source, req.headers, body.rawBody); + if (!verification.ok) { + const adaptedForReject = adaptWebhookPayload(source, req.headers, body.jsonBody, body.rawBody); + const log = createRejectedGatewayLog({ + sourceKey: source.key, + provider: source.provider, + eventType: adaptedForReject.eventType, + actor: source.actor ?? `webhook:${source.key}`, + statusCode: 401, + signatureVerified: verification.verified, + message: verification.message, + deliveryId: adaptedForReject.deliveryId, + payloadDigest: sha256Hex(body.rawBody), + }); + appendWebhookGatewayLog(workspacePath, log); + writeWebhookGatewayHttpResponse(res, 401, { + ok: false, + error: verification.message, + }); + return; + } + + const adapted = adaptWebhookPayload(source, req.headers, body.jsonBody, body.rawBody); + const payloadDigest = sha256Hex(body.rawBody); + const actor = source.actor ?? `webhook:${source.key}`; + + if (source.provider === 'slack' && isSlackChallengePayload(body.jsonBody)) { + const challenge = String((body.jsonBody as Record).challenge ?? ''); + const acceptedLog: WebhookGatewayLogEntry = { + id: randomUUID(), + ts: new Date().toISOString(), + sourceKey: source.key, + provider: source.provider, + eventType: adapted.eventType, + actor, + status: 'accepted', + statusCode: 200, + signatureVerified: verification.verified, + message: 'Slack URL verification challenge accepted.', + deliveryId: adapted.deliveryId, + payloadDigest, + }; + appendWebhookGatewayLog(workspacePath, acceptedLog); + writeWebhookGatewayHttpResponse(res, 200, { + ok: true, + challenge, + source: source.key, + eventType: adapted.eventType, + }); + return; + } + + appendWebhookGatewayLedgerEvent(workspacePath, source, { + eventType: adapted.eventType, + deliveryId: adapted.deliveryId, + payload: adapted.payload, + payloadDigest, + actor, + }); + const acceptedLog: WebhookGatewayLogEntry = { + id: randomUUID(), + ts: new Date().toISOString(), + sourceKey: source.key, + provider: source.provider, + eventType: adapted.eventType, + actor, + status: 'accepted', + statusCode: 202, + signatureVerified: verification.verified, + message: verification.message, + deliveryId: adapted.deliveryId, + payloadDigest, + }; + appendWebhookGatewayLog(workspacePath, acceptedLog); + writeWebhookGatewayHttpResponse(res, 202, { + ok: true, + accepted: true, + source: source.key, + provider: source.provider, + eventType: adapted.eventType, + deliveryId: adapted.deliveryId, + }); + } catch (error) { + writeWebhookGatewayHttpResponse(res, 500, { + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }); +} + +function readWebhookGatewayStore(workspacePath: string): WebhookGatewayStoreFile { + const filePath = webhookGatewayStorePath(workspacePath); + if (!fs.existsSync(filePath)) { + return { + version: WEBHOOK_GATEWAY_STORE_VERSION, + sources: [], + }; + } + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Partial; + const sources = Array.isArray(parsed.sources) + ? parsed.sources + .map((entry) => sanitizeStoredSource(entry)) + .filter((entry): entry is StoredWebhookGatewaySource => entry !== null) + : []; + return { + version: WEBHOOK_GATEWAY_STORE_VERSION, + sources, + }; + } catch { + return { + version: WEBHOOK_GATEWAY_STORE_VERSION, + sources: [], + }; + } +} + +function writeWebhookGatewayStore(workspacePath: string, store: WebhookGatewayStoreFile): void { + const filePath = webhookGatewayStorePath(workspacePath); + ensureParentDirectory(filePath); + const serialized: WebhookGatewayStoreFile = { + version: WEBHOOK_GATEWAY_STORE_VERSION, + sources: store.sources.map((source) => ({ + id: source.id, + key: source.key, + provider: source.provider, + createdAt: source.createdAt, + enabled: source.enabled, + ...(source.secret ? { secret: source.secret } : {}), + ...(source.actor ? { actor: source.actor } : {}), + ...(source.eventPrefix ? { eventPrefix: source.eventPrefix } : {}), + })), + }; + fs.writeFileSync(filePath, `${JSON.stringify(serialized, null, 2)}\n`, 'utf-8'); +} + +function resolveSourceByKeyOrId(workspacePath: string, keyOrId: string): StoredWebhookGatewaySource | null { + const normalized = String(keyOrId ?? '').trim(); + if (!normalized) return null; + const store = readWebhookGatewayStore(workspacePath); + return store.sources.find((source) => source.key === normalized || source.id === normalized) ?? null; +} + +function verifyWebhookSignature( + source: StoredWebhookGatewaySource, + headers: Record, + rawBody: string, +): SignatureVerificationResult { + const secret = readOptionalString(source.secret); + if (!secret) { + return { + ok: true, + verified: false, + message: 'Accepted unsigned webhook (source has no secret configured).', + }; + } + + switch (source.provider) { + case 'github': + return verifyGithubSignature(headers, rawBody, secret); + case 'slack': + return verifySlackSignature(headers, rawBody, secret); + case 'linear': + return verifyLinearSignature(headers, rawBody, secret); + case 'generic': + return verifyGenericSignature(headers, rawBody, secret); + default: + return { + ok: false, + verified: false, + message: `Unsupported webhook gateway provider: ${source.provider}`, + }; + } +} + +function verifyGithubSignature( + headers: Record, + rawBody: string, + secret: string, +): SignatureVerificationResult { + const signature = readHeader(headers, 'x-hub-signature-256'); + if (!signature) { + return { + ok: false, + verified: false, + message: 'Missing GitHub signature header: x-hub-signature-256.', + }; + } + const expected = `sha256=${hmacSha256Hex(secret, rawBody)}`; + if (!safeSignaturesMatch(signature, [expected])) { + return { + ok: false, + verified: false, + message: 'GitHub signature verification failed.', + }; + } + return { + ok: true, + verified: true, + message: 'GitHub signature verified.', + }; +} + +function verifySlackSignature( + headers: Record, + rawBody: string, + secret: string, +): SignatureVerificationResult { + const signature = readHeader(headers, 'x-slack-signature'); + const timestampRaw = readHeader(headers, 'x-slack-request-timestamp'); + if (!signature || !timestampRaw) { + return { + ok: false, + verified: false, + message: 'Missing Slack signature headers.', + }; + } + const timestampSeconds = Number.parseInt(timestampRaw, 10); + if (!Number.isFinite(timestampSeconds)) { + return { + ok: false, + verified: false, + message: 'Invalid Slack signature timestamp.', + }; + } + const nowSeconds = Math.floor(Date.now() / 1_000); + if (Math.abs(nowSeconds - timestampSeconds) > SLACK_SIGNATURE_MAX_AGE_SECONDS) { + return { + ok: false, + verified: false, + message: 'Slack signature timestamp is outside the accepted time window.', + }; + } + const base = `v0:${timestampRaw}:${rawBody}`; + const expected = `v0=${hmacSha256Hex(secret, base)}`; + if (!safeSignaturesMatch(signature, [expected])) { + return { + ok: false, + verified: false, + message: 'Slack signature verification failed.', + }; + } + return { + ok: true, + verified: true, + message: 'Slack signature verified.', + }; +} + +function verifyLinearSignature( + headers: Record, + rawBody: string, + secret: string, +): SignatureVerificationResult { + const signature = readHeader(headers, 'linear-signature') ?? readHeader(headers, 'x-linear-signature'); + if (!signature) { + return { + ok: false, + verified: false, + message: 'Missing Linear signature header.', + }; + } + const expectedHex = hmacSha256Hex(secret, rawBody); + const expectedBase64 = hmacSha256Base64(secret, rawBody); + if (!safeSignaturesMatch(signature, [expectedHex, `sha256=${expectedHex}`, expectedBase64])) { + return { + ok: false, + verified: false, + message: 'Linear signature verification failed.', + }; + } + return { + ok: true, + verified: true, + message: 'Linear signature verified.', + }; +} + +function verifyGenericSignature( + headers: Record, + rawBody: string, + secret: string, +): SignatureVerificationResult { + const signature = readHeader(headers, 'x-workgraph-signature') + ?? readHeader(headers, 'x-webhook-signature') + ?? readHeader(headers, 'x-signature'); + if (!signature) { + return { + ok: false, + verified: false, + message: 'Missing generic webhook signature header.', + }; + } + const expectedHex = hmacSha256Hex(secret, rawBody); + if (!safeSignaturesMatch(signature, [expectedHex, `sha256=${expectedHex}`])) { + return { + ok: false, + verified: false, + message: 'Generic signature verification failed.', + }; + } + return { + ok: true, + verified: true, + message: 'Generic signature verified.', + }; +} + +function adaptWebhookPayload( + source: StoredWebhookGatewaySource, + headers: Record, + jsonBody: unknown, + rawBody: string, +): AdaptedWebhookPayload { + const fallbackDeliveryId = deriveFallbackDeliveryId(rawBody); + const prefix = normalizeEventPrefix(source.eventPrefix ?? source.provider); + + if (source.provider === 'github') { + const githubEvent = readHeader(headers, 'x-github-event') + ?? readRecordString(jsonBody, 'action') + ?? 'unknown'; + const deliveryId = readHeader(headers, 'x-github-delivery') ?? fallbackDeliveryId; + return { + eventType: normalizeEventType(`webhook.${prefix}.${normalizeEventToken(githubEvent)}`), + deliveryId, + payload: jsonBody, + }; + } + + if (source.provider === 'linear') { + const action = readRecordString(jsonBody, 'action') ?? 'unknown'; + const entityType = readRecordString(jsonBody, 'type') + ?? readRecordString(jsonBody, 'entity') + ?? 'event'; + const deliveryId = readHeader(headers, 'linear-delivery') + ?? readHeader(headers, 'x-linear-delivery') + ?? fallbackDeliveryId; + return { + eventType: normalizeEventType( + `webhook.${prefix}.${normalizeEventToken(entityType)}.${normalizeEventToken(action)}`, + ), + deliveryId, + payload: jsonBody, + }; + } + + if (source.provider === 'slack') { + const topLevelType = readRecordString(jsonBody, 'type') ?? 'unknown'; + const event = readRecordValue(jsonBody, 'event'); + const nestedEventType = readRecordString(event, 'type'); + const deliveryId = readRecordString(jsonBody, 'event_id') + ?? readHeader(headers, 'x-slack-request-timestamp') + ?? fallbackDeliveryId; + const suffix = nestedEventType + ? `${normalizeEventToken(topLevelType)}.${normalizeEventToken(nestedEventType)}` + : normalizeEventToken(topLevelType); + return { + eventType: normalizeEventType(`webhook.${prefix}.${suffix}`), + deliveryId, + payload: jsonBody, + }; + } + + const genericEvent = readHeader(headers, 'x-webhook-event') + ?? readHeader(headers, 'x-event-type') + ?? readRecordString(jsonBody, 'event') + ?? readRecordString(jsonBody, 'type') + ?? 'received'; + const genericDelivery = readHeader(headers, 'x-webhook-delivery') + ?? readHeader(headers, 'x-request-id') + ?? fallbackDeliveryId; + return { + eventType: normalizeEventType(`webhook.${prefix}.${normalizeEventToken(genericEvent)}`), + deliveryId: genericDelivery, + payload: jsonBody, + }; +} + +function appendWebhookGatewayLedgerEvent( + workspacePath: string, + source: StoredWebhookGatewaySource, + input: { + eventType: string; + deliveryId: string; + payload: unknown; + payloadDigest: string; + actor: string; + }, +): void { + const safeDeliveryId = normalizeDeliveryId(input.deliveryId) ?? deriveFallbackDeliveryId(input.payloadDigest); + const target = `.workgraph/webhook-gateway/${source.key}/${safeDeliveryId}`; + ledger.append(workspacePath, input.actor, 'update', target, 'event', { + event_type: input.eventType, + provider: source.provider, + source_key: source.key, + delivery_id: safeDeliveryId, + payload_digest: input.payloadDigest, + payload: input.payload, + }); +} + +function appendWebhookGatewayLog(workspacePath: string, entry: WebhookGatewayLogEntry): void { + const filePath = webhookGatewayLogPath(workspacePath); + ensureParentDirectory(filePath); + fs.appendFileSync(filePath, `${JSON.stringify(entry)}\n`, 'utf-8'); +} + +function createRejectedGatewayLog(input: { + sourceKey: string; + provider: WebhookGatewayProvider; + eventType: string; + actor: string; + statusCode: number; + signatureVerified: boolean; + message: string; + payloadDigest: string; + deliveryId?: string; +}): WebhookGatewayLogEntry { + return { + id: randomUUID(), + ts: new Date().toISOString(), + sourceKey: input.sourceKey, + provider: input.provider, + eventType: normalizeEventType(input.eventType), + actor: input.actor, + status: 'rejected', + statusCode: input.statusCode, + signatureVerified: input.signatureVerified, + message: input.message, + ...(input.deliveryId ? { deliveryId: input.deliveryId } : {}), + payloadDigest: input.payloadDigest, + }; +} + +async function resolveWebhookBody(req: any): Promise<{ rawBody: string; jsonBody: unknown }> { + if (Buffer.isBuffer(req.body)) { + const rawBody = req.body.toString('utf-8'); + return { + rawBody, + jsonBody: safeParseJson(rawBody), + }; + } + if (typeof req.body === 'string') { + return { + rawBody: req.body, + jsonBody: safeParseJson(req.body), + }; + } + if (req.body && typeof req.body === 'object') { + return { + rawBody: stringifyPayload(req.body), + jsonBody: req.body, + }; + } + if (Buffer.isBuffer(req.rawBody)) { + const rawBody = req.rawBody.toString('utf-8'); + return { + rawBody, + jsonBody: safeParseJson(rawBody), + }; + } + if (typeof req.rawBody === 'string') { + return { + rawBody: req.rawBody, + jsonBody: safeParseJson(req.rawBody), + }; + } + + const streamBody = await readRequestBody(req); + return { + rawBody: streamBody, + jsonBody: safeParseJson(streamBody), + }; +} + +async function readRequestBody(req: any): Promise { + const chunks: Buffer[] = []; + let totalBytes = 0; + await new Promise((resolve, reject) => { + req.on('data', (chunk: Buffer | string) => { + const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + totalBytes += bufferChunk.byteLength; + if (totalBytes > MAX_WEBHOOK_BODY_BYTES) { + reject(new Error(`Webhook payload exceeds ${MAX_WEBHOOK_BODY_BYTES} bytes.`)); + return; + } + chunks.push(bufferChunk); + }); + req.on('end', () => resolve()); + req.on('error', (error: unknown) => reject(error)); + }); + return Buffer.concat(chunks).toString('utf-8'); +} + +function sanitizeStoredSource(raw: unknown): StoredWebhookGatewaySource | null { + if (!raw || typeof raw !== 'object') return null; + const candidate = raw as Partial; + const id = readOptionalString(candidate.id); + const key = readOptionalString(candidate.key); + const provider = normalizeProvider(candidate.provider); + const createdAt = readOptionalString(candidate.createdAt) ?? new Date(0).toISOString(); + if (!id || !key || !provider) return null; + return { + id, + key, + provider, + createdAt, + enabled: candidate.enabled !== false, + ...(readOptionalString(candidate.secret) ? { secret: readOptionalString(candidate.secret)! } : {}), + ...(readOptionalString(candidate.actor) ? { actor: readOptionalString(candidate.actor)! } : {}), + ...(readOptionalString(candidate.eventPrefix) ? { eventPrefix: readOptionalString(candidate.eventPrefix)! } : {}), + }; +} + +function sanitizeWebhookGatewayLogEntry(raw: unknown): WebhookGatewayLogEntry | null { + if (!raw || typeof raw !== 'object') return null; + const candidate = raw as Partial; + const id = readOptionalString(candidate.id); + const ts = readOptionalString(candidate.ts); + const sourceKey = readOptionalString(candidate.sourceKey); + const provider = normalizeProvider(candidate.provider); + const eventType = readOptionalString(candidate.eventType); + const actor = readOptionalString(candidate.actor); + const status = candidate.status === 'accepted' || candidate.status === 'rejected' + ? candidate.status + : undefined; + const statusCode = Number.isFinite(Number(candidate.statusCode)) + ? Number(candidate.statusCode) + : undefined; + const signatureVerified = Boolean(candidate.signatureVerified); + const message = readOptionalString(candidate.message); + const payloadDigest = readOptionalString(candidate.payloadDigest); + if (!id || !ts || !sourceKey || !provider || !eventType || !actor || !status || statusCode === undefined || !message || !payloadDigest) { + return null; + } + return { + id, + ts, + sourceKey, + provider, + eventType, + actor, + status, + statusCode, + signatureVerified, + message, + ...(readOptionalString(candidate.deliveryId) ? { deliveryId: readOptionalString(candidate.deliveryId)! } : {}), + payloadDigest, + }; +} + +function toWebhookGatewaySourceView(source: StoredWebhookGatewaySource): WebhookGatewaySourceView { + return { + id: source.id, + key: source.key, + provider: source.provider, + createdAt: source.createdAt, + enabled: source.enabled, + hasSecret: typeof source.secret === 'string' && source.secret.length > 0, + ...(source.actor ? { actor: source.actor } : {}), + ...(source.eventPrefix ? { eventPrefix: source.eventPrefix } : {}), + }; +} + +function safeSignaturesMatch(actual: string, candidates: string[]): boolean { + const normalizedActual = actual.trim(); + for (const candidate of candidates) { + const normalizedCandidate = candidate.trim(); + if (!normalizedCandidate) continue; + if (timingSafeEquals(normalizedActual, normalizedCandidate)) return true; + } + return false; +} + +function timingSafeEquals(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + if (leftBuffer.length !== rightBuffer.length) return false; + return crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function hmacSha256Hex(secret: string, payload: string): string { + return crypto.createHmac('sha256', secret).update(payload).digest('hex'); +} + +function hmacSha256Base64(secret: string, payload: string): string { + return crypto.createHmac('sha256', secret).update(payload).digest('base64'); +} + +function normalizeProvider(value: unknown): WebhookGatewayProvider | null { + const normalized = String(value ?? '').trim().toLowerCase(); + if ( + normalized === 'github' + || normalized === 'linear' + || normalized === 'slack' + || normalized === 'generic' + ) { + return normalized; + } + return null; +} + +function normalizeSourceKey(value: unknown): string { + const normalized = String(value ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, ''); + if (!normalized) { + throw new Error('Webhook gateway source key is required.'); + } + return normalized; +} + +function normalizeEventPrefix(value: unknown): string { + const normalized = String(value ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, '-') + .replace(/^-+|-+$/g, ''); + return normalized || 'generic'; +} + +function normalizeEventToken(value: unknown): string { + const normalized = String(value ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, '.') + .replace(/\.+/g, '.') + .replace(/^\.+|\.+$/g, ''); + return normalized || 'unknown'; +} + +function normalizeEventType(value: unknown): string { + const normalized = String(value ?? '').trim().toLowerCase(); + if (!normalized) return 'webhook.unknown'; + return normalized; +} + +function normalizeDeliveryId(value: unknown): string | undefined { + const normalized = String(value ?? '') + .trim() + .replace(/[^a-zA-Z0-9._:-]+/g, '-') + .replace(/^-+|-+$/g, ''); + return normalized || undefined; +} + +function normalizeLogLimit(value: unknown): number { + const parsed = Number.parseInt(String(value ?? DEFAULT_LOG_LIMIT), 10); + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LOG_LIMIT; + return Math.min(MAX_LOG_LIMIT, parsed); +} + +function readHeader(headers: Record, key: string): string | undefined { + const lowercaseKey = key.toLowerCase(); + for (const [headerKey, headerValue] of Object.entries(headers ?? {})) { + if (headerKey.toLowerCase() !== lowercaseKey) continue; + if (Array.isArray(headerValue)) { + return readOptionalString(headerValue[0]); + } + return readOptionalString(headerValue); + } + return undefined; +} + +function readOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function readRecordString(value: unknown, key: string): string | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + return readOptionalString((value as Record)[key]); +} + +function readRecordValue(value: unknown, key: string): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + return (value as Record)[key]; +} + +function isSlackChallengePayload(value: unknown): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + return record.type === 'url_verification' && typeof record.challenge === 'string'; +} + +function safeParseJson(text: string): unknown { + const trimmed = text.trim(); + if (!trimmed) return {}; + try { + return JSON.parse(trimmed) as unknown; + } catch { + return { + raw: text, + }; + } +} + +function stringifyPayload(payload: unknown): string { + if (typeof payload === 'string') return payload; + try { + return JSON.stringify(payload); + } catch { + return '{}'; + } +} + +function deriveFallbackDeliveryId(seed: string): string { + return sha256Hex(seed).slice(0, 16); +} + +function sha256Hex(value: string): string { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function ensureParentDirectory(filePath: string): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function webhookGatewayStorePath(workspacePath: string): string { + return path.join(workspacePath, WEBHOOK_GATEWAY_STORE_PATH); +} + +function webhookGatewayLogPath(workspacePath: string): string { + return path.join(workspacePath, WEBHOOK_GATEWAY_LOG_PATH); +} + +function writeWebhookGatewayHttpResponse( + res: any, + status: number, + payload: Record, +): void { + res.status(status).json(payload); +}