feat: outbound webhooks — signed event delivery + delivery log

Every audited action now emits a "resource.action" event (bare actions
get resource-qualified; already-qualified actions like service.clone
pass through). Webhooks subscribe per-user with exact names, resource.*
wildcards, or *.

- schema: outbound_webhooks + webhook_deliveries (sqlc + goose)
- dispatcher: 4 workers, 512-queue, 3 attempts with backoff, 10s timeout;
  deliveries record status/http-code/body(4KiB)/duration
- signing: X-Containr-Signature sha256=<hmac> over raw body;
  X-Containr-Event + X-Containr-Delivery headers; custom headers per hook
- api: CRUD + /deliveries + /test (ping) under authed group
- fix: SetAuditDB never called — LogAudit was a silent no-op at every
  call site; now wired at boot
- coverage: service create/delete audited (previously uninstrumented)
- cli: containr webhooks list|create|update|delete|deliveries|test
- mcp: 6 tools, delete requires confirm
- frontend: /settings/webhooks page — list/create/expand-deliveries/
  test/disable/delete, secret-shown-once banner; nav + account menu
- openapi + regenerated types; CLI.md + docs snapshot; roadmap ticks
This commit is contained in:
Tomas Dvorak committed 2026-10-09 09:44:08 +02:00
1 parent 5c2f2861bd
commit f499678964
23 files changed
+2278 -7

No files matched your search

+60
View File
@@ -1767,6 +1767,66 @@ export async function importComposeIntoProject(
return { created: payload.created ?? [], warnings: payload.warnings ?? [] };
}
export type OutboundWebhook = {
id: string;
name: string;
url: string;
secret?: string;
events: string[];
headers?: Record<string, string>;
enabled: boolean;
created_at: string;
updated_at: string;
};
export type WebhookDelivery = {
id: string;
webhook_id: string;
event: string;
status: 'pending' | 'success' | 'failed';
response_status?: number | null;
response_body?: string | null;
attempts: number;
duration_ms?: number | null;
created_at: string;
delivered_at?: string | null;
};
export async function listWebhooks(): Promise<OutboundWebhook[]> {
const payload = await requestJson<{ webhooks?: OutboundWebhook[] }>('/webhooks');
return payload.webhooks ?? [];
}
export type WebhookInput = {
name: string;
url: string;
events: string[];
secret?: string;
headers?: Record<string, string>;
enabled?: boolean;
};
export async function createWebhook(input: WebhookInput): Promise<OutboundWebhook> {
return requestJson<OutboundWebhook>('/webhooks', { method: 'POST', body: JSON.stringify(input) });
}
export async function updateWebhook(id: string, input: Partial<WebhookInput>): Promise<OutboundWebhook> {
return requestJson<OutboundWebhook>(`/webhooks/${id}`, { method: 'PATCH', body: JSON.stringify(input) });
}
export async function deleteWebhook(id: string): Promise<void> {
await requestJson(`/webhooks/${id}`, { method: 'DELETE' });
}
export async function listWebhookDeliveries(id: string): Promise<WebhookDelivery[]> {
const payload = await requestJson<{ deliveries?: WebhookDelivery[] }>(`/webhooks/${id}/deliveries`);
return payload.deliveries ?? [];
}
export async function testWebhook(id: string): Promise<void> {
await requestJson(`/webhooks/${id}/test`, { method: 'POST', body: JSON.stringify({}) });
}
export function serviceStatusClass(status: ServiceStatus): string {
switch (status) {
case 'running':