From 3945e5508347c6d11a550cc208a56f2808082fd9 Mon Sep 17 00:00:00 2001 From: josunday002 Date: Mon, 29 Jun 2026 17:19:28 +0100 Subject: [PATCH 1/4] feat: add automated weekly/monthly report scheduling to ComplianceReports Boards and governance committees need reports delivered automatically without manual action. Adds schedule creation UI with frequency selection (weekly/monthly), recipient management, and localStorage persistence. --- frontend/src/components/ComplianceReports.tsx | 252 +++++++++++++++++- 1 file changed, 250 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ComplianceReports.tsx b/frontend/src/components/ComplianceReports.tsx index 1a1baadc..84ec3c39 100644 --- a/frontend/src/components/ComplianceReports.tsx +++ b/frontend/src/components/ComplianceReports.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from 'react'; -import { FileText, Download, Calendar, Eye, CheckSquare, X, Table } from 'lucide-react'; +import React, { useState, useEffect, useCallback } from 'react'; +import { FileText, Download, Calendar, Eye, CheckSquare, X, Table, Clock, Trash2, Mail } from 'lucide-react'; import { generateSOC2Report, generateISO27001Report } from '../utils/reportGenerator'; import { exportCompliancePDF, exportComplianceCSV } from '../utils/reportExport'; import { useVaultContract } from '../hooks/useVaultContract'; @@ -9,6 +9,7 @@ import { prepareChainedAuditLog } from '../utils/auditVerification'; import { env } from '../config/env'; type ReportType = 'SOC2' | 'ISO27001' | 'Custom'; +type ScheduleFrequency = 'weekly' | 'monthly'; interface ReportConfig { type: ReportType; @@ -17,6 +18,44 @@ interface ReportConfig { sections: string[]; } +interface ReportSchedule { + id: string; + reportType: ReportType; + frequency: ScheduleFrequency; + dayOfWeek?: number; + dayOfMonth?: number; + recipients: string[]; + sections: string[]; + enabled: boolean; + createdAt: string; + lastRunAt?: string; + nextRunAt: string; +} + +interface ScheduleFormState { + frequency: ScheduleFrequency; + dayOfWeek: number; + dayOfMonth: number; + recipients: string; +} + +function computeNextRun(frequency: ScheduleFrequency, dayOfWeek: number, dayOfMonth: number): string { + const now = new Date(); + const next = new Date(now); + + if (frequency === 'weekly') { + const currentDay = now.getDay(); + const daysUntil = (dayOfWeek - currentDay + 7) % 7 || 7; + next.setDate(now.getDate() + daysUntil); + } else { + next.setMonth(now.getMonth() + 1); + next.setDate(Math.min(dayOfMonth, new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate())); + } + + next.setHours(8, 0, 0, 0); + return next.toISOString(); +} + const SOC2_SECTIONS = [ 'Security', 'Availability', @@ -52,6 +91,74 @@ const ComplianceReports: React.FC = () => { sections: SOC2_SECTIONS, }); + const [schedules, setSchedules] = useState(() => { + try { + const saved = localStorage.getItem('vaultdao_report_schedules'); + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } + }); + + const [scheduleForm, setScheduleForm] = useState({ + frequency: 'weekly', + dayOfWeek: 1, + dayOfMonth: 1, + recipients: '', + }); + + const [showSchedulePanel, setShowSchedulePanel] = useState(false); + + const persistSchedules = useCallback((updated: ReportSchedule[]) => { + setSchedules(updated); + localStorage.setItem('vaultdao_report_schedules', JSON.stringify(updated)); + }, []); + + const handleCreateSchedule = () => { + const recipients = scheduleForm.recipients + .split(',') + .map(r => r.trim()) + .filter(r => r.length > 0); + + if (recipients.length === 0) { + notify('schedule_error', 'At least one recipient email is required', 'error'); + return; + } + + if (config.sections.length === 0) { + notify('schedule_error', 'Select at least one report section', 'error'); + return; + } + + const schedule: ReportSchedule = { + id: crypto.randomUUID(), + reportType: config.type, + frequency: scheduleForm.frequency, + dayOfWeek: scheduleForm.frequency === 'weekly' ? scheduleForm.dayOfWeek : undefined, + dayOfMonth: scheduleForm.frequency === 'monthly' ? scheduleForm.dayOfMonth : undefined, + recipients, + sections: [...config.sections], + enabled: true, + createdAt: new Date().toISOString(), + nextRunAt: computeNextRun(scheduleForm.frequency, scheduleForm.dayOfWeek, scheduleForm.dayOfMonth), + }; + + persistSchedules([...schedules, schedule]); + setScheduleForm({ frequency: 'weekly', dayOfWeek: 1, dayOfMonth: 1, recipients: '' }); + notify('schedule_created', `${config.type} ${scheduleForm.frequency} schedule created`, 'success'); + }; + + const handleDeleteSchedule = (id: string) => { + persistSchedules(schedules.filter(s => s.id !== id)); + notify('schedule_deleted', 'Schedule removed', 'success'); + }; + + const handleToggleSchedule = (id: string) => { + persistSchedules( + schedules.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s) + ); + }; + useEffect(() => { fetchAuditData(); }, []); @@ -560,6 +667,147 @@ const ComplianceReports: React.FC = () => {
{config.sections.length}
+ + {/* Automated Report Scheduling */} +
+
+

+ + Automated Report Delivery +

+ +
+ + {showSchedulePanel && ( +
+
+
+ + +
+ + {scheduleForm.frequency === 'weekly' ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+ +
+ + setScheduleForm(prev => ({ ...prev, recipients: e.target.value }))} + placeholder="alice@example.com, bob@example.com" + className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-purple-500" + /> +
+ +
+

+ Report type: {config.type} · {config.sections.length} sections selected +

+ +
+
+ )} + + {schedules.length === 0 ? ( +

+ No scheduled reports. Create one to automate weekly or monthly delivery. +

+ ) : ( +
+ {schedules.map(schedule => ( +
+
+
+ {schedule.reportType} + + {schedule.frequency} + + {!schedule.enabled && ( + paused + )} +
+

+ To: {schedule.recipients.join(', ')} +

+

+ Next run: {new Date(schedule.nextRunAt).toLocaleDateString()} + {schedule.lastRunAt && ` · Last: ${new Date(schedule.lastRunAt).toLocaleDateString()}`} +

+
+
+ + +
+
+ ))} +
+ )} +
); From 63a24fe18a96cf9a85c6b01f87bb140596dc5ae8 Mon Sep 17 00:00:00 2001 From: josunday002 Date: Mon, 29 Jun 2026 17:19:31 +0100 Subject: [PATCH 2/4] feat: add full-text search to SQLite proposal activity adapter Every proposal query previously hit the Stellar RPC with no local index. Adds FTS5 virtual table with auto-sync triggers, ranked search across proposal_id/activity_type/actor/data, scoped search by proposal, and an index rebuild utility for bulk imports. --- .../proposals/adapters/sqlite-adapter.ts | 81 ++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/backend/src/modules/proposals/adapters/sqlite-adapter.ts b/backend/src/modules/proposals/adapters/sqlite-adapter.ts index ada09258..3a041d7f 100644 --- a/backend/src/modules/proposals/adapters/sqlite-adapter.ts +++ b/backend/src/modules/proposals/adapters/sqlite-adapter.ts @@ -27,6 +27,35 @@ const CREATE_TABLE_SQL = ` ON proposal_activity (contract_id, timestamp ASC); `; +const CREATE_FTS_SQL = ` + CREATE VIRTUAL TABLE IF NOT EXISTS proposal_activity_fts + USING fts5( + proposal_id, + activity_type, + actor, + data, + content='proposal_activity', + content_rowid='id' + ); + + CREATE TRIGGER IF NOT EXISTS proposal_activity_ai AFTER INSERT ON proposal_activity BEGIN + INSERT INTO proposal_activity_fts(rowid, proposal_id, activity_type, actor, data) + VALUES (new.id, new.proposal_id, new.activity_type, new.actor, new.data); + END; + + CREATE TRIGGER IF NOT EXISTS proposal_activity_ad AFTER DELETE ON proposal_activity BEGIN + INSERT INTO proposal_activity_fts(proposal_activity_fts, rowid, proposal_id, activity_type, actor, data) + VALUES ('delete', old.id, old.proposal_id, old.activity_type, old.actor, old.data); + END; + + CREATE TRIGGER IF NOT EXISTS proposal_activity_au AFTER UPDATE ON proposal_activity BEGIN + INSERT INTO proposal_activity_fts(proposal_activity_fts, rowid, proposal_id, activity_type, actor, data) + VALUES ('delete', old.id, old.proposal_id, old.activity_type, old.actor, old.data); + INSERT INTO proposal_activity_fts(rowid, proposal_id, activity_type, actor, data) + VALUES (new.id, new.proposal_id, new.activity_type, new.actor, new.data); + END; +`; + // ─── Row type (raw from SQLite) ─────────────────────────────────────────── interface ActivityRow { @@ -76,6 +105,8 @@ export class SqliteProposalActivityAdapter implements SyncProposalActivityPersis private readonly stmtGetByProposalId: any; private readonly stmtGetByContractId: any; private readonly stmtSummary: any; + private readonly stmtFtsSearch: any; + private readonly stmtFtsSearchByProposal: any; constructor(databasePath: string) { this.db = new Database(databasePath); @@ -84,8 +115,9 @@ export class SqliteProposalActivityAdapter implements SyncProposalActivityPersis this.db.pragma("journal_mode = WAL"); this.db.pragma("foreign_keys = ON"); - // Create table and indexes (idempotent) + // Create table, indexes, and FTS virtual table (idempotent) this.db.exec(CREATE_TABLE_SQL); + this.db.exec(CREATE_FTS_SQL); // Prepare statements this.stmtInsert = this.db.prepare(` @@ -123,6 +155,25 @@ export class SqliteProposalActivityAdapter implements SyncProposalActivityPersis WHERE proposal_id = ? GROUP BY proposal_id, contract_id `); + + this.stmtFtsSearch = this.db.prepare(` + SELECT pa.* + FROM proposal_activity_fts fts + JOIN proposal_activity pa ON pa.id = fts.rowid + WHERE proposal_activity_fts MATCH ? + ORDER BY fts.rank + LIMIT ? + `); + + this.stmtFtsSearchByProposal = this.db.prepare(` + SELECT pa.* + FROM proposal_activity_fts fts + JOIN proposal_activity pa ON pa.id = fts.rowid + WHERE proposal_activity_fts MATCH ? + AND pa.proposal_id = ? + ORDER BY fts.rank + LIMIT ? + `); } // ── save ───────────────────────────────────────────────────────────────── @@ -196,6 +247,34 @@ export class SqliteProposalActivityAdapter implements SyncProposalActivityPersis return row ?? null; } + // ── search (full-text) ─────────────────────────────────────────────────── + + /** + * Full-text search across proposal activity records. + * Searches proposal_id, activity_type, actor, and data fields. + * Uses FTS5 ranking for relevance ordering. + */ + search(query: string, limit: number = 50): ProposalActivity[] { + const rows = this.stmtFtsSearch.all(query, limit) as ActivityRow[]; + return rows.map(rowToActivity); + } + + /** + * Full-text search scoped to a single proposal. + */ + searchByProposal(proposalId: string, query: string, limit: number = 50): ProposalActivity[] { + const rows = this.stmtFtsSearchByProposal.all(query, proposalId, limit) as ActivityRow[]; + return rows.map(rowToActivity); + } + + /** + * Rebuild the FTS index from scratch. Useful after bulk imports that + * bypass the trigger-based sync (e.g. restoring from a backup). + */ + rebuildFtsIndex(): void { + this.db.exec(`INSERT INTO proposal_activity_fts(proposal_activity_fts) VALUES ('rebuild')`); + } + // ── close ──────────────────────────────────────────────────────────────── /** From 073fc3e65937088342e1136163074da3ff16f740 Mon Sep 17 00:00:00 2001 From: josunday002 Date: Mon, 29 Jun 2026 17:20:44 +0100 Subject: [PATCH 3/4] feat: add timestamp-based HMAC signatures and verification to webhooks Webhooks now include X-VaultDAO-Timestamp and X-VaultDAO-Delivery-Id headers. Signature computation includes the timestamp to prevent replay attacks. Adds exported verifyWebhookSignature utility with timing-safe comparison and configurable max-age window (default 5 min). --- .../modules/notifications/webhook.service.ts | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/backend/src/modules/notifications/webhook.service.ts b/backend/src/modules/notifications/webhook.service.ts index a72ed797..5b6a3dbc 100644 --- a/backend/src/modules/notifications/webhook.service.ts +++ b/backend/src/modules/notifications/webhook.service.ts @@ -14,7 +14,7 @@ * - Delivery timeout is 10 seconds per attempt. */ -import { createHash, createHmac, randomUUID } from "node:crypto"; +import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto"; import { createLogger } from "../../shared/logging/logger.js"; import type { NotificationEvent } from "./notification.types.js"; import type { DeliveryRecord, WebhookRegistration } from "./notification.types.js"; @@ -58,8 +58,36 @@ function hashSecret(secret: string): string { return createHash("sha256").update(secret).digest("hex"); } -function signPayload(secret: string, body: string): string { - return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; +function signPayload(secret: string, body: string, timestamp: number): string { + const message = `${timestamp}.${body}`; + return `sha256=${createHmac("sha256", secret).update(message).digest("hex")}`; +} + +/** + * Verify an incoming webhook signature. Receivers call this to confirm + * the payload originated from VaultDAO. + * Rejects payloads older than `maxAgeMs` (default 5 minutes) to prevent replay attacks. + */ +export function verifyWebhookSignature( + secret: string, + body: string, + signature: string, + timestamp: number, + maxAgeMs: number = 300_000, +): boolean { + const age = Math.abs(Date.now() - timestamp); + if (age > maxAgeMs) return false; + + const expected = signPayload(secret, body, timestamp); + if (expected.length !== signature.length) return false; + + const a = Buffer.from(expected); + const b = Buffer.from(signature); + try { + return timingSafeEqual(a, b); + } catch { + return false; + } } function sleep(ms: number): Promise { @@ -181,7 +209,9 @@ export class WebhookDeliveryService { webhook: StoredWebhookRegistration, ): Promise { const body = JSON.stringify(event); - const signature = signPayload(webhook.secretRaw, body); + const timestamp = Date.now(); + const signature = signPayload(webhook.secretRaw, body, timestamp); + const deliveryId = randomUUID(); let lastError: string | null = null; @@ -208,6 +238,8 @@ export class WebhookDeliveryService { headers: { "Content-Type": "application/json", "X-VaultDAO-Signature": signature, + "X-VaultDAO-Timestamp": String(timestamp), + "X-VaultDAO-Delivery-Id": deliveryId, "X-VaultDAO-Event": event.topic, }, body, From 8570d91ec5d421ff43992ce2d60e8dc2efed9a9b Mon Sep 17 00:00:00 2001 From: josunday002 Date: Mon, 29 Jun 2026 17:23:17 +0100 Subject: [PATCH 4/4] feat: add dead-letter queue for failed webhook deliveries Failed deliveries after all retries are now captured in a dead-letter queue instead of being silently dropped. Adds replay, per-webhook filtering, single-entry removal, and bulk purge operations so operators can investigate and retry failed payloads. --- .../modules/notifications/webhook.service.ts | 103 +++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/backend/src/modules/notifications/webhook.service.ts b/backend/src/modules/notifications/webhook.service.ts index 5b6a3dbc..3bd6cde7 100644 --- a/backend/src/modules/notifications/webhook.service.ts +++ b/backend/src/modules/notifications/webhook.service.ts @@ -94,11 +94,24 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +// ── Dead-letter entry ───────────────────────────────────────────────────────── + +export interface DeadLetterEntry { + readonly id: string; + readonly webhookId: string; + readonly event: NotificationEvent; + readonly lastError: string; + readonly attempts: number; + readonly failedAt: string; + replayed: boolean; +} + // ── Service ─────────────────────────────────────────────────────────────────── export class WebhookDeliveryService { private readonly webhooks = new Map(); private readonly deliveryStore: StorageAdapter; + private readonly deadLetters: Map = new Map(); constructor( deliveryStore?: StorageAdapter, @@ -188,6 +201,79 @@ export class WebhookDeliveryService { ); } + // ── Dead-letter queue ───────────────────────────────────────────────────── + + /** + * Return all entries in the dead-letter queue. + */ + public getDeadLetters(): DeadLetterEntry[] { + return Array.from(this.deadLetters.values()); + } + + /** + * Return dead-letter entries for a specific webhook. + */ + public getDeadLettersForWebhook(webhookId: string): DeadLetterEntry[] { + return Array.from(this.deadLetters.values()).filter( + (dl) => dl.webhookId === webhookId, + ); + } + + /** + * Replay a dead-letter entry: re-deliver the original event to its webhook. + * Marks the entry as replayed on success, or updates the error on failure. + * @returns true if the replay delivery succeeded. + */ + public async replayDeadLetter(deadLetterId: string): Promise { + const entry = this.deadLetters.get(deadLetterId); + if (!entry) { + throw new Error(`Dead-letter entry not found: ${deadLetterId}`); + } + + const webhook = this.webhooks.get(entry.webhookId); + if (!webhook) { + throw new Error(`Webhook no longer registered: ${entry.webhookId}`); + } + + logger.info("replaying dead-letter entry", { + deadLetterId, + webhookId: webhook.id, + eventId: entry.event.id, + }); + + try { + await this.deliverToWebhook(entry.event, webhook); + entry.replayed = true; + this.deadLetters.delete(deadLetterId); + return true; + } catch (err) { + logger.warn("dead-letter replay failed", { + deadLetterId, + error: err instanceof Error ? err.message : String(err), + }); + return false; + } + } + + /** + * Remove all entries from the dead-letter queue. + * @returns the number of entries purged. + */ + public purgeDeadLetters(): number { + const count = this.deadLetters.size; + this.deadLetters.clear(); + if (count > 0) logger.info("dead-letter queue purged", { count }); + return count; + } + + /** + * Remove a single dead-letter entry. + * @returns true if found and removed. + */ + public removeDeadLetter(deadLetterId: string): boolean { + return this.deadLetters.delete(deadLetterId); + } + // ── Internal ────────────────────────────────────────────────────────────── private validateHttpsUrl(url: string): void { @@ -273,10 +359,23 @@ export class WebhookDeliveryService { } } - // All attempts exhausted + // All attempts exhausted — move to dead-letter queue await this.recordDelivery(webhook.id, event.id, "failed", MAX_ATTEMPTS, lastError); - logger.error("webhook delivery exhausted", { + + const dlEntry: DeadLetterEntry = { + id: randomUUID(), + webhookId: webhook.id, + event, + lastError: lastError ?? "unknown error", + attempts: MAX_ATTEMPTS, + failedAt: new Date().toISOString(), + replayed: false, + }; + this.deadLetters.set(dlEntry.id, dlEntry); + + logger.error("webhook delivery exhausted, moved to dead-letter queue", { webhookId: webhook.id, + deadLetterId: dlEntry.id, url: webhook.url, eventId: event.id, error: lastError,