diff --git a/apps/api/src/broker/outboxWorker.ts b/apps/api/src/broker/outboxWorker.ts index d0eaf82..afbeaa1 100644 --- a/apps/api/src/broker/outboxWorker.ts +++ b/apps/api/src/broker/outboxWorker.ts @@ -130,18 +130,10 @@ interface DispatchResult { } async function dispatchRow(row: OutboxRow): Promise { - // Load the authenticated client for this row's owner. - // In test environments loadClientForUser may fail (db mock mismatch) — fall back - // to createFastmailClient with empty credentials (mocked in tests to return fake client). - let client: FastmailClient - try { - client = await loadClientForUser(row.userId) - } catch { - // Unit-test path: db mock returns outbox rows for any select → decryptPassword throws. - // createFastmailClient is mocked and ignores credentials, so this still works. - // Production path: this branch is never taken (real Drizzle query succeeds). - client = await createFastmailClient('', '') - } + // CR-03: fail closed on credential errors — let loadClientForUser throw. + // The outer per-row catch in runOutboxDrain logs and leaves the row pending (correct transient behavior). + // Do NOT add an empty-credential fallback — that would silently PUT with no authentication. + const client = await loadClientForUser(row.userId) let response: Response @@ -364,7 +356,9 @@ export async function runOutboxDrain(): Promise { failedCreateGroups.add(row.groupId) } } else { - const backoffMs = (BACKOFF_SECONDS[nextAttemptCount] ?? 1800) * 1000 + // WR-01: use row.attemptCount (the attempt that just failed, 0-based) as the backoff index. + // This makes the first retry wait BACKOFF_SECONDS[0]=15s, not BACKOFF_SECONDS[1]=60s. + const backoffMs = (BACKOFF_SECONDS[row.attemptCount] ?? 1800) * 1000 await db .update(calendarOutbox) .set({ diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index ae6e743..1b0e5fc 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -21,6 +21,7 @@ * Mounted under /api/* in index.ts — behind oidcAuthMiddleware. */ +import { randomUUID } from 'node:crypto' import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' @@ -253,7 +254,7 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) => } // Generate a UID for the new event (Node.js 22 built-in) - const uid = `${crypto.randomUUID()}@familysync` + const uid = `${randomUUID()}@familysync` // Enqueue the outbox row (pending) — the worker builds the VEVENT and calls Fastmail. await db.insert(calendarOutbox).values({ @@ -325,8 +326,8 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c if (isCalendarMove) { // D-04: edit-as-move — insert delete+create pair in one transaction (D-04 / Pitfall 5) - const newUid = `${crypto.randomUUID()}@familysync` - const groupId = crypto.randomUUID() + const newUid = `${randomUUID()}@familysync` + const groupId = randomUUID() await db.transaction(async (tx) => { // Delete from old calendar