From 95f9d8c097c699fd20a5824798b76cc5fcbf36e7 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 10:42:41 -0400 Subject: [PATCH] fix(03): IN-01 cache decrypted client per userId within a drain cycle --- apps/api/src/broker/outboxWorker.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/api/src/broker/outboxWorker.ts b/apps/api/src/broker/outboxWorker.ts index f4dc79f..8c661c2 100644 --- a/apps/api/src/broker/outboxWorker.ts +++ b/apps/api/src/broker/outboxWorker.ts @@ -104,11 +104,24 @@ async function loadClientForUser(userId: number): Promise { * Triggers a targeted single-calendar re-sync after a successful write or 412 conflict. * Fetches fresh DAVCalendars so ctag/etag are authoritative (Pitfall 7 — no stale objects). * All errors are caught and logged — re-sync failure is non-fatal. + * + * IN-01: accepts an optional per-drain-cycle client cache. Without it, every settled or + * conflicted row independently reloaded + AES-GCM-decrypted the member credential, + * widening the window the decrypted app password lives in memory (T-03-13). When a cache + * is supplied, the decrypted client is built at most once per userId per drain cycle. */ -async function triggerTargetedResync(calendarUrl: string, userId: number): Promise { +async function triggerTargetedResync( + calendarUrl: string, + userId: number, + clientCache?: Map, +): Promise { try { // loadClientForUser may throw in test environments — caught below - const client = await loadClientForUser(userId) + let client = clientCache?.get(userId) + if (!client) { + client = await loadClientForUser(userId) + clientCache?.set(userId, client) + } const davCalendars = await client.fetchCalendars() // Pitfall 7: find the DAVCalendar by URL match (normalize trailing slash differences) @@ -382,6 +395,10 @@ export async function runOutboxDrain(): Promise { // Cross-batch ordering is enforced durably by the DB sibling-status check inside the loop. const failedCreateGroups = new Set() + // IN-01: per-drain-cycle client cache so triggerTargetedResync decrypts each member's + // credential at most once per cycle. Discarded when the drain returns — never persisted. + const clientCache = new Map() + for (const row of sorted) { // D-04 fast path: if the create for this group already failed in this batch, skip the delete if (row.operation === 'delete' && row.groupId && failedCreateGroups.has(row.groupId)) { @@ -452,7 +469,7 @@ export async function runOutboxDrain(): Promise { .update(calendarOutbox) .set({ status: 'failed', lastError: conflictError }) .where(eq(calendarOutbox.id, row.id)) - await triggerTargetedResync(row.calendarUrl, row.userId) + await triggerTargetedResync(row.calendarUrl, row.userId, clientCache) if (row.groupId && row.operation === 'create') { failedCreateGroups.add(row.groupId) @@ -464,7 +481,7 @@ export async function runOutboxDrain(): Promise { // raced the re-sync and returned stale cache (deleted event still // present, edit not yet applied) — forcing a manual refresh. Re-syncing // first means 'done' guarantees the cache already reflects the write. - await triggerTargetedResync(row.calendarUrl, row.userId) + await triggerTargetedResync(row.calendarUrl, row.userId, clientCache) await db .update(calendarOutbox) .set({ status: 'done' })