feat: add Docker support and inbound webhooks (Tier 2)

- Dockerfile: multi-stage build (node:22-alpine), better-sqlite3 native deps handled
- .dockerignore + docker-compose.yml for deployment
- FLYNN_DATA_DIR env var support in daemon, CLI, and TUI
- WebhookHandler: ChannelAdapter for HTTP POST /webhooks/:name
- Per-webhook HMAC auth, template rendering ({{body}}, {{json.field}})
- Config schema: automation.webhooks array with name/secret/message/output
- Gateway routes webhook requests before static files (bypasses gateway auth)
- 23 new tests for webhook functionality, 874 total tests passing
This commit is contained in:
William Valentin
2026-02-07 14:36:05 -08:00
parent b322e8f29c
commit b50c140d25
12 changed files with 1927 additions and 7 deletions
+35
View File
@@ -0,0 +1,35 @@
node_modules
dist
.git
.gitignore
.worktrees
# Logs
*.log
# Environment files
.env
.env.*
!.env.example
# Documentation (keep README)
docs/
*.md
!README.md
!SOUL.md
# Test files
src/**/*.test.ts
vitest.config.*
# Lint config
eslint.config.*
# Editor / IDE
.vscode
.idea
*.swp
*.swo
# Claude config
.claude
+72
View File
@@ -0,0 +1,72 @@
# ── Builder stage ──────────────────────────────────────────────
FROM node:22-alpine AS builder
# Enable corepack for pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Install native build tools (needed for better-sqlite3)
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copy dependency manifests first (layer caching)
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Install dependencies (frozen lockfile for reproducibility)
RUN pnpm install --frozen-lockfile
# Copy source and config
COPY tsconfig.json ./
COPY src/ src/
COPY config/ config/
# Build TypeScript
RUN pnpm build
# ── Runtime stage ─────────────────────────────────────────────
FROM node:22-alpine
# Label
LABEL org.opencontainers.image.title="Flynn" \
org.opencontainers.image.description="Self-hosted personal AI agent" \
org.opencontainers.image.source="https://github.com/will666/flynn"
WORKDIR /app
# Copy node_modules from builder (includes compiled native deps like better-sqlite3)
COPY --from=builder /app/node_modules/ node_modules/
# Copy compiled output
COPY --from=builder /app/dist/ dist/
# Copy gateway UI static files into dist/gateway/ui so import.meta.dirname
# resolution from dist/daemon/index.js (../gateway/ui) resolves correctly
COPY --from=builder /app/src/gateway/ui/ dist/gateway/ui/
# Copy default config
COPY --from=builder /app/config/ config/
# Copy package.json (needed for bin resolution / metadata)
COPY --from=builder /app/package.json ./
# Copy SOUL.md if it exists (prompt template loaded at runtime)
COPY --from=builder /app/SOUL.md ./
# Create data directories
RUN mkdir -p /data/memory /data/sessions /config
# Environment
ENV NODE_ENV=production \
FLYNN_CONFIG=/config/config.yaml \
FLYNN_DATA_DIR=/data
# Gateway port
EXPOSE 18800
# Health check — verify the gateway is responding
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:18800/ || exit 1
ENTRYPOINT ["node", "dist/cli/index.js"]
CMD ["start"]
+34
View File
@@ -0,0 +1,34 @@
services:
flynn:
build: .
container_name: flynn
restart: unless-stopped
ports:
- "18800:18800"
volumes:
# Persistent data (sessions DB, memory store)
- flynn-data:/data
# Mount your config file
- ./config/default.yaml:/config/config.yaml:ro
environment:
# Required: at least one model provider API key
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
# Optional: additional provider keys
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
# Optional: Telegram integration
- FLYNN_TELEGRAM_TOKEN=${FLYNN_TELEGRAM_TOKEN:-}
# Optional: Discord integration
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}
# Optional: Gateway auth token
- FLYNN_SERVER_TOKEN=${FLYNN_SERVER_TOKEN:-}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:18800/"]
interval: 30s
timeout: 5s
start_period: 15s
retries: 3
volumes:
flynn-data:
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1 +1,2 @@
export { CronScheduler } from './cron.js';
export { WebhookHandler } from './webhooks.js';
+307
View File
@@ -0,0 +1,307 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { WebhookHandler, _verifyHmac, _renderTemplate } from './webhooks.js';
import type { WebhookConfig } from '../config/schema.js';
import type { InboundMessage } from '../channels/types.js';
import type { IncomingMessage, ServerResponse } from 'http';
import { createHmac } from 'crypto';
import { EventEmitter } from 'events';
function makeWebhook(overrides?: Partial<WebhookConfig>): WebhookConfig {
return {
name: 'test-hook',
message: '{{body}}',
output: { channel: 'telegram', peer: '123' },
enabled: true,
...overrides,
};
}
/** Create a mock IncomingMessage that emits the given body. */
function mockRequest(body: string, headers: Record<string, string> = {}): IncomingMessage {
const emitter = new EventEmitter();
(emitter as any).headers = headers;
// Simulate data arriving next tick
process.nextTick(() => {
emitter.emit('data', Buffer.from(body));
emitter.emit('end');
});
return emitter as unknown as IncomingMessage;
}
/** Create a mock ServerResponse that captures writeHead and end calls. */
function mockResponse(): ServerResponse & { statusCode_: number; body_: string; headers_: Record<string, string> } {
const res: any = {
statusCode_: 0,
body_: '',
headers_: {},
writeHead(code: number, headers?: Record<string, string>) {
res.statusCode_ = code;
if (headers) res.headers_ = headers;
return res;
},
end(body?: string) {
res.body_ = body ?? '';
return res;
},
};
return res;
}
describe('WebhookHandler', () => {
let handler: WebhookHandler;
let mockChannelRegistry: { get: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockChannelRegistry = {
get: vi.fn(),
};
});
afterEach(async () => {
if (handler) {
await handler.disconnect();
}
});
it('implements ChannelAdapter interface', () => {
handler = new WebhookHandler([], mockChannelRegistry as any);
expect(handler.name).toBe('webhook');
expect(handler.status).toBe('disconnected');
});
it('status changes to connected after connect()', async () => {
handler = new WebhookHandler([], mockChannelRegistry as any);
await handler.connect();
expect(handler.status).toBe('connected');
});
it('status changes to disconnected after disconnect()', async () => {
handler = new WebhookHandler([], mockChannelRegistry as any);
await handler.connect();
await handler.disconnect();
expect(handler.status).toBe('disconnected');
});
it('lists registered webhook names', () => {
const webhooks = [
makeWebhook({ name: 'hook-a' }),
makeWebhook({ name: 'hook-b', enabled: false }),
];
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
const names = handler.getWebhookNames();
expect(names).toEqual(['hook-a', 'hook-b']);
});
it('handleRequest produces correct InboundMessage', async () => {
const webhooks = [makeWebhook()];
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
const messages: InboundMessage[] = [];
handler.onMessage((msg: InboundMessage) => messages.push(msg));
await handler.connect();
const req = mockRequest('hello world');
const res = mockResponse();
const result = await handler.handleRequest('test-hook', req, res);
expect(result).toBe(true);
expect(res.statusCode_).toBe(202);
expect(messages).toHaveLength(1);
expect(messages[0].channel).toBe('webhook');
expect(messages[0].senderId).toBe('test-hook');
expect(messages[0].text).toBe('hello world');
});
it('returns false for unknown webhook', async () => {
handler = new WebhookHandler([], mockChannelRegistry as any);
await handler.connect();
const req = mockRequest('test');
const res = mockResponse();
const result = await handler.handleRequest('nonexistent', req, res);
expect(result).toBe(false);
expect(res.statusCode_).toBe(404);
});
it('returns false for disabled webhook', async () => {
const webhooks = [makeWebhook({ enabled: false })];
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
await handler.connect();
const req = mockRequest('test');
const res = mockResponse();
const result = await handler.handleRequest('test-hook', req, res);
expect(result).toBe(false);
expect(res.statusCode_).toBe(404);
});
it('verifies valid HMAC signature', async () => {
const secret = 'my-secret-key';
const webhooks = [makeWebhook({ secret })];
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
const messages: InboundMessage[] = [];
handler.onMessage((msg: InboundMessage) => messages.push(msg));
await handler.connect();
const body = '{"event":"push"}';
const signature = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
const req = mockRequest(body, { 'x-webhook-signature': signature });
const res = mockResponse();
const result = await handler.handleRequest('test-hook', req, res);
expect(result).toBe(true);
expect(res.statusCode_).toBe(202);
expect(messages).toHaveLength(1);
});
it('rejects invalid HMAC signature', async () => {
const secret = 'my-secret-key';
const webhooks = [makeWebhook({ secret })];
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
const messages: InboundMessage[] = [];
handler.onMessage((msg: InboundMessage) => messages.push(msg));
await handler.connect();
const req = mockRequest('{"event":"push"}', { 'x-webhook-signature': 'sha256=invalid' });
const res = mockResponse();
const result = await handler.handleRequest('test-hook', req, res);
expect(result).toBe(false);
expect(res.statusCode_).toBe(401);
expect(messages).toHaveLength(0);
});
it('rejects missing HMAC signature when secret is configured', async () => {
const secret = 'my-secret-key';
const webhooks = [makeWebhook({ secret })];
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
const messages: InboundMessage[] = [];
handler.onMessage((msg: InboundMessage) => messages.push(msg));
await handler.connect();
const req = mockRequest('{"event":"push"}');
const res = mockResponse();
const result = await handler.handleRequest('test-hook', req, res);
expect(result).toBe(false);
expect(res.statusCode_).toBe(401);
expect(messages).toHaveLength(0);
});
it('forwards response to output channel on send()', async () => {
const mockOutputAdapter = {
send: vi.fn().mockResolvedValue(undefined),
};
mockChannelRegistry.get.mockReturnValue(mockOutputAdapter);
const webhooks = [makeWebhook()];
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
await handler.connect();
await handler.send('test-hook', { text: 'Agent response' });
expect(mockChannelRegistry.get).toHaveBeenCalledWith('telegram');
expect(mockOutputAdapter.send).toHaveBeenCalledWith('123', { text: 'Agent response' });
});
it('logs warning when output channel not found', async () => {
mockChannelRegistry.get.mockReturnValue(undefined);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const webhooks = [makeWebhook()];
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
await handler.connect();
await handler.send('test-hook', { text: 'Agent response' });
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Output channel'));
warnSpy.mockRestore();
});
it('logs warning when webhook name not found in send()', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const webhooks = [makeWebhook()];
handler = new WebhookHandler(webhooks, mockChannelRegistry as any);
await handler.connect();
await handler.send('nonexistent-hook', { text: 'response' });
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No webhook'));
warnSpy.mockRestore();
});
});
describe('renderTemplate', () => {
it('replaces {{body}} with raw body', () => {
const result = _renderTemplate('Received: {{body}}', 'hello');
expect(result).toBe('Received: hello');
});
it('replaces {{json.field}} with JSON field value', () => {
const result = _renderTemplate('Event: {{json.action}}', '{"action":"push","repo":"test"}');
expect(result).toBe('Event: push');
});
it('replaces multiple {{json.field}} placeholders', () => {
const result = _renderTemplate(
'{{json.action}} on {{json.repo}}',
'{"action":"push","repo":"my-repo"}',
);
expect(result).toBe('push on my-repo');
});
it('returns empty string for missing JSON fields', () => {
const result = _renderTemplate('Value: {{json.missing}}', '{"action":"push"}');
expect(result).toBe('Value: ');
});
it('returns empty string for invalid JSON body with json placeholder', () => {
const result = _renderTemplate('Value: {{json.field}}', 'not-json');
expect(result).toBe('Value: ');
});
it('stringifies non-string JSON values', () => {
const result = _renderTemplate('Count: {{json.count}}', '{"count":42}');
expect(result).toBe('Count: 42');
});
it('handles template with both {{body}} and {{json.field}}', () => {
const body = '{"action":"deploy"}';
const result = _renderTemplate('Action: {{json.action}}, Raw: {{body}}', body);
expect(result).toBe('Action: deploy, Raw: {"action":"deploy"}');
});
});
describe('verifyHmac', () => {
it('returns true for valid signature with sha256= prefix', () => {
const secret = 'test-secret';
const body = 'test-body';
const sig = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
expect(_verifyHmac(body, secret, sig)).toBe(true);
});
it('returns true for valid signature without prefix', () => {
const secret = 'test-secret';
const body = 'test-body';
const sig = createHmac('sha256', secret).update(body).digest('hex');
expect(_verifyHmac(body, secret, sig)).toBe(true);
});
it('returns false for invalid signature', () => {
expect(_verifyHmac('body', 'secret', 'sha256=deadbeef')).toBe(false);
});
});
+171
View File
@@ -0,0 +1,171 @@
import { createHmac, timingSafeEqual } from 'crypto';
import type { IncomingMessage, ServerResponse } from 'http';
import type { WebhookConfig } from '../config/schema.js';
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
/** Minimal interface for the parts of ChannelRegistry we need. */
interface ChannelLookup {
get(name: string): { send(peerId: string, message: OutboundMessage): Promise<void> } | undefined;
}
/** Read the full request body as a string. */
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});
}
/** Verify HMAC-SHA256 signature from the X-Webhook-Signature header. */
function verifyHmac(body: string, secret: string, signature: string): boolean {
const expected = createHmac('sha256', secret).update(body).digest('hex');
const sig = signature.startsWith('sha256=') ? signature.slice(7) : signature;
if (expected.length !== sig.length) return false;
try {
return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex'));
} catch {
return false;
}
}
/**
* Render a message template with {{body}} and {{json.field}} placeholders.
* - {{body}} is replaced with the raw request body.
* - {{json.field}} accesses a top-level field from the parsed JSON body.
*/
function renderTemplate(template: string, body: string): string {
let result = template.replace(/\{\{body\}\}/g, body);
// Replace {{json.field}} placeholders
let parsed: Record<string, unknown> | undefined;
result = result.replace(/\{\{json\.([^}]+)\}\}/g, (_match, field: string) => {
if (!parsed) {
try {
parsed = JSON.parse(body) as Record<string, unknown>;
} catch {
return '';
}
}
const value = parsed[field];
if (value === undefined || value === null) return '';
return typeof value === 'string' ? value : JSON.stringify(value);
});
return result;
}
export class WebhookHandler implements ChannelAdapter {
readonly name = 'webhook';
private _status: ChannelStatus = 'disconnected';
private messageHandler?: (msg: InboundMessage) => void;
private webhooks: Map<string, WebhookConfig> = new Map();
constructor(
private readonly webhookConfigs: WebhookConfig[],
private readonly channelLookup: ChannelLookup,
) {
for (const webhook of webhookConfigs) {
this.webhooks.set(webhook.name, webhook);
}
}
get status(): ChannelStatus {
return this._status;
}
async connect(): Promise<void> {
this._status = 'connected';
const enabledCount = this.webhookConfigs.filter(w => w.enabled).length;
if (enabledCount > 0) {
console.log(`WebhookHandler: ${enabledCount} webhook(s) registered`);
}
}
async disconnect(): Promise<void> {
this._status = 'disconnected';
}
async send(peerId: string, message: OutboundMessage): Promise<void> {
// peerId is the webhook name — look up its output config
const webhook = this.webhooks.get(peerId);
if (!webhook) {
console.warn(`No webhook found for '${peerId}'`);
return;
}
const outputAdapter = this.channelLookup.get(webhook.output.channel);
if (!outputAdapter) {
console.warn(`Output channel '${webhook.output.channel}' not found for webhook '${peerId}'`);
return;
}
await outputAdapter.send(webhook.output.peer, message);
}
onMessage(handler: (msg: InboundMessage) => void): void {
this.messageHandler = handler;
}
/**
* Handle an incoming HTTP webhook request.
* Returns true if the webhook was found and processed, false otherwise.
*/
async handleRequest(webhookName: string, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
const webhook = this.webhooks.get(webhookName);
if (!webhook) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unknown webhook' }));
return false;
}
if (!webhook.enabled) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Webhook disabled' }));
return false;
}
const body = await readBody(req);
// Verify HMAC if secret is configured
if (webhook.secret) {
const signature = req.headers['x-webhook-signature'] as string | undefined;
if (!signature || !verifyHmac(body, webhook.secret, signature)) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid signature' }));
return false;
}
}
// Render message template
const text = renderTemplate(webhook.message, body);
const msg: InboundMessage = {
id: `webhook-${webhookName}-${Date.now()}`,
channel: 'webhook',
senderId: webhookName,
senderName: `webhook:${webhookName}`,
text,
timestamp: Date.now(),
metadata: { webhookName, body },
};
this.messageHandler?.(msg);
res.writeHead(202, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ accepted: true }));
return true;
}
/** Get list of all webhook names (enabled and disabled). */
getWebhookNames(): string[] {
return Array.from(this.webhooks.keys());
}
}
// Export helpers for testing
export { readBody as _readBody, verifyHmac as _verifyHmac, renderTemplate as _renderTemplate };
+2 -2
View File
@@ -8,9 +8,9 @@ export function getConfigPath(): string {
return process.env.FLYNN_CONFIG ?? resolve(homedir(), '.config/flynn/config.yaml');
}
/** Get the data directory path. */
/** Get the data directory path (FLYNN_DATA_DIR overrides default for Docker/custom deployments). */
export function getDataDir(): string {
return resolve(homedir(), '.local/share/flynn');
return process.env.FLYNN_DATA_DIR ?? resolve(homedir(), '.local/share/flynn');
}
/** Load config without throwing. Returns { config } or { error }. */
+1 -1
View File
@@ -49,7 +49,7 @@ export function registerTuiCommand(program: Command): void {
const { HookEngine } = await import('../hooks/index.js');
const { createModelRouter } = await import('../daemon/index.js');
const dataDir = resolve(homedir(), '.local/share/flynn');
const dataDir = process.env.FLYNN_DATA_DIR ?? resolve(homedir(), '.local/share/flynn');
mkdirSync(dataDir, { recursive: true });
const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db'));
+13
View File
@@ -108,8 +108,20 @@ const cronJobSchema = z.object({
timezone: z.string().optional(),
});
const webhookSchema = z.object({
name: z.string().min(1, 'Webhook name is required'),
secret: z.string().optional(),
message: z.string().default('{{body}}'),
output: z.object({
channel: z.string().min(1),
peer: z.string().min(1),
}),
enabled: z.boolean().default(true),
});
const automationSchema = z.object({
cron: z.array(cronJobSchema).default([]),
webhooks: z.array(webhookSchema).default([]),
}).default({});
const agentsSchema = z.object({
@@ -299,6 +311,7 @@ export type Config = z.infer<typeof configSchema>;
export type TelegramConfig = z.infer<typeof telegramSchema>;
export type ModelConfig = z.infer<typeof modelConfigSchema>;
export type CronJobConfig = z.infer<typeof cronJobSchema>;
export type WebhookConfig = z.infer<typeof webhookSchema>;
export type AgentsConfig = z.infer<typeof agentsSchema>;
export type CompactionConfig = z.infer<typeof compactionSchema>;
export type MemoryConfig = z.infer<typeof memorySchema>;
+12 -3
View File
@@ -15,7 +15,7 @@ import { MemoryStore } from '../memory/index.js';
import { createMemoryTools } from '../tools/builtin/index.js';
import { GatewayServer } from '../gateway/index.js';
import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter } from '../channels/index.js';
import { CronScheduler } from '../automation/index.js';
import { CronScheduler, WebhookHandler } from '../automation/index.js';
import type { InboundMessage, OutboundMessage } from '../channels/index.js';
import { McpManager } from '../mcp/index.js';
import { SkillRegistry, SkillInstaller, loadAllSkills } from '../skills/index.js';
@@ -521,8 +521,8 @@ function createMessageRouter(deps: {
export async function startDaemon(config: Config): Promise<DaemonContext> {
const lifecycle = new Lifecycle();
// Ensure data directory exists
const dataDir = resolve(homedir(), '.local/share/flynn');
// Ensure data directory exists (FLYNN_DATA_DIR overrides default for Docker/custom deployments)
const dataDir = process.env.FLYNN_DATA_DIR ?? resolve(homedir(), '.local/share/flynn');
mkdirSync(dataDir, { recursive: true });
// Initialize memory store
@@ -816,6 +816,15 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
console.log(`Registered ${config.automation.cron.length} cron job(s)`);
}
// Register webhook handler adapter (if any webhooks configured)
let webhookHandler: WebhookHandler | undefined;
if (config.automation.webhooks.length > 0) {
webhookHandler = new WebhookHandler(config.automation.webhooks, channelRegistry);
channelRegistry.register(webhookHandler);
gateway.setWebhookHandler(webhookHandler);
console.log(`Registered ${config.automation.webhooks.length} webhook(s)`);
}
// ── Register Tier 1 agent tools ─────────────────────────────
// Session management tools (list, history, create, delete)
+19 -1
View File
@@ -24,6 +24,7 @@ import type { SessionManager } from '../session/manager.js';
import type { Config } from '../config/index.js';
import type { ToolRegistry } from '../tools/registry.js';
import type { ToolExecutor } from '../tools/executor.js';
import type { WebhookHandler } from '../automation/webhooks.js';
export interface GatewayServerConfig {
port: number;
@@ -42,6 +43,8 @@ export interface GatewayServerConfig {
/** Optional callback for system.restart. Should trigger graceful shutdown + process restart. */
restart?: () => Promise<void>;
channelRegistry?: { list(): Array<{ readonly name: string; readonly status: string }> };
/** Optional webhook handler for inbound webhook HTTP routes. */
webhookHandler?: WebhookHandler;
}
export class GatewayServer {
@@ -207,9 +210,19 @@ export class GatewayServer {
/**
* Handle incoming HTTP requests.
* Optionally applies auth (when authHttp is enabled and a token is configured).
* Delegates to serveStatic for UI files; returns 404 if no UI dir or file not found.
* Routes webhook requests before auth; delegates to serveStatic for UI files.
*/
private async handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
// Webhook routes bypass gateway auth (they have their own HMAC auth)
if (this.config.webhookHandler && req.method === 'POST' && req.url) {
const match = req.url.match(/^\/webhooks\/([^/?]+)/);
if (match) {
const webhookName = decodeURIComponent(match[1]);
await this.config.webhookHandler.handleRequest(webhookName, req, res);
return;
}
}
// Apply auth to HTTP requests when configured
const authConfig = this.config.auth ?? {};
if (this.config.authHttp !== false && authConfig.token) {
@@ -281,4 +294,9 @@ export class GatewayServer {
getMethods(): string[] {
return this.router.listMethods();
}
/** Set the webhook handler for inbound webhook HTTP routes (late binding). */
setWebhookHandler(handler: WebhookHandler): void {
this.config.webhookHandler = handler;
}
}