feat(audit): Add automation component logging

Add audit logging to:
- WebhookHandler: connect/disconnect, receive, not_found, denied, HMAC verified
- HeartbeatMonitor: start/stop, cycle, check, fail, recover
- GmailWatcher: connect/disconnect lifecycle events

All automation events now captured in audit log with proper context
This commit is contained in:
William Valentin
2026-02-11 16:04:33 -08:00
parent d62e836b5d
commit 2dddae8f9b
3 changed files with 47 additions and 2 deletions
+4 -1
View File
@@ -6,6 +6,7 @@ import type { GmailConfig } from '../config/schema.js';
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
import { parseInterval } from './heartbeat.js';
import { sanitizeHtml } from '../utils/html.js';
import { auditLogger } from '../audit/index.js';
/** Minimal interface for the parts of ChannelRegistry we need. */
interface ChannelLookup {
@@ -99,6 +100,7 @@ export class GmailWatcher implements ChannelAdapter {
this._status = 'connected';
console.log(`GmailWatcher: Connected (poll_interval=${this.config.poll_interval ?? '300s'})`);
auditLogger?.systemStart('GmailWatcher', { poll_interval: this.config.poll_interval });
}
async disconnect(): Promise<void> {
@@ -107,11 +109,12 @@ export class GmailWatcher implements ChannelAdapter {
this.pollTimer = undefined;
}
if (this.watchTimer) {
clearInterval(this.watchTimer);
clearTimeout(this.watchTimer);
this.watchTimer = undefined;
}
this.oauth2Client = undefined;
this._status = 'disconnected';
auditLogger?.systemStop('GmailWatcher');
}
async send(peerId: string, message: OutboundMessage): Promise<void> {
+26
View File
@@ -2,6 +2,7 @@ import { statfsSync, accessSync, constants as fsConstants } from 'fs';
import { request } from 'http';
import type { HeartbeatConfig, HeartbeatCheck } from '../config/schema.js';
import type { ChannelAdapter, ChannelStatus, OutboundMessage } from '../channels/types.js';
import { auditLogger } from '../audit/index.js';
/** Result of a single health check. */
export interface CheckResult {
@@ -77,6 +78,7 @@ export class HeartbeatMonitor {
const intervalMs = parseInterval(this.deps.config.interval);
console.log(`HeartbeatMonitor: starting (interval=${this.deps.config.interval}, checks=[${this.deps.config.checks.join(', ')}])`);
auditLogger?.systemStart('HeartbeatMonitor', { interval: this.deps.config.interval, checks: this.deps.config.checks });
this.timer = setInterval(() => {
this.runChecks().catch((err) => {
@@ -96,6 +98,7 @@ export class HeartbeatMonitor {
clearInterval(this.timer);
this.timer = undefined;
}
auditLogger?.systemStop('HeartbeatMonitor');
}
/** Run all configured checks and return the result. */
@@ -136,6 +139,12 @@ export class HeartbeatMonitor {
}
checks.push(result);
auditLogger?.heartbeatCheck({
check_name: result.name,
healthy: result.healthy,
message: result.message,
duration_ms: result.durationMs,
});
}
const healthy = checks.every((c) => c.healthy);
@@ -154,16 +163,33 @@ export class HeartbeatMonitor {
this.notifiedFailure = true;
const failedChecks = checks.filter((c) => !c.healthy).map((c) => `${c.name}: ${c.message}`);
await this.notify(`Heartbeat FAILING (${this.consecutiveFailures} consecutive failures):\n${failedChecks.join('\n')}`);
auditLogger?.heartbeatFail({
checks_failed: failedChecks,
consecutive_failures: this.consecutiveFailures,
threshold: this.deps.config.failure_threshold,
});
}
} else {
if (this.notifiedFailure) {
// Recovery notification
await this.notify(`Heartbeat RECOVERED after ${this.consecutiveFailures} consecutive failure(s). All checks passing.`);
auditLogger?.heartbeatRecover({
consecutive_failures_before: this.consecutiveFailures,
});
}
this.consecutiveFailures = 0;
this.notifiedFailure = false;
}
auditLogger?.heartbeatCycle({
interval_ms: parseInterval(this.deps.config.interval),
checks: this.deps.config.checks,
healthy,
consecutive_failures: this.consecutiveFailures,
});
return heartbeatResult;
}
+17 -1
View File
@@ -2,6 +2,7 @@ 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';
import { auditLogger } from '../audit/index.js';
/** Minimal interface for the parts of ChannelRegistry we need. */
interface ChannelLookup {
@@ -83,11 +84,13 @@ export class WebhookHandler implements ChannelAdapter {
const enabledCount = this.webhookConfigs.filter(w => w.enabled).length;
if (enabledCount > 0) {
console.log(`WebhookHandler: ${enabledCount} webhook(s) registered`);
auditLogger?.systemStart('WebhookHandler', { webhooks_enabled: enabledCount });
}
}
async disconnect(): Promise<void> {
this._status = 'disconnected';
auditLogger?.systemStop('WebhookHandler');
}
async send(peerId: string, message: OutboundMessage): Promise<void> {
@@ -118,12 +121,14 @@ export class WebhookHandler implements ChannelAdapter {
async handleRequest(webhookName: string, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
const webhook = this.webhooks.get(webhookName);
if (!webhook) {
auditLogger?.webhookNotFound(webhookName);
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unknown webhook' }));
return false;
}
if (!webhook.enabled) {
auditLogger?.webhookDenied(webhookName, 'Webhook disabled');
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Webhook disabled' }));
return false;
@@ -132,9 +137,12 @@ export class WebhookHandler implements ChannelAdapter {
const body = await readBody(req);
// Verify HMAC if secret is configured
const signatureVerified = !webhook.secret;
if (webhook.secret) {
const signature = req.headers['x-webhook-signature'] as string | undefined;
if (!signature || !verifyHmac(body, webhook.secret, signature)) {
const verified = signature ? verifyHmac(body, webhook.secret, signature) : false;
if (!verified) {
auditLogger?.webhookDenied(webhookName, 'Invalid HMAC signature');
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid signature' }));
return false;
@@ -154,6 +162,14 @@ export class WebhookHandler implements ChannelAdapter {
metadata: { webhookName, body },
};
auditLogger?.webhookReceive({
webhook_name: webhookName,
body,
signature_verified: signatureVerified,
output_channel: webhook.output.channel,
output_peer: webhook.output.peer,
});
this.messageHandler?.(msg);
res.writeHead(202, { 'Content-Type': 'application/json' });