/** * Shared validate→encrypt→store→initial-sync helper for credential management. * * Used by BOTH: * - POST /api/admin/credentials (admin rotating any member's credential) * - POST /api/me/credential (member self-service, D-07) * * Security contract (T-10-09, T-10-10, T-10-11): * - createFastmailClient + client.fetchCalendars() are wrapped in a single try/catch. * ANY throw (bad email, malformed input, network error, PROPFIND/auth failure) is * treated identically as a credential-validation failure → CredentialValidationError. * - The caller maps CredentialValidationError to { error: 'Invalid request' } 400. * - The submitted appPassword is NEVER logged or echoed back to the caller. * - On success: encryptPassword (AES-256-GCM) before the DB write (T-10-11). * - Initial sync is fire-and-forget after the DB upsert (Pitfall 5: encrypt+upsert first). */ import { db } from '../db/client.js'; import { memberCredentials } from '../db/schema.js'; import { encryptPassword } from './crypto.js'; import { createFastmailClient } from './client.js'; import { loadClientForUser } from './outboxWorker.js'; import { syncCalendar } from './sync.js'; import type { FastmailClient } from './client.js'; /** * Signals a credential validation failure (createFastmailClient throw, network error, * PROPFIND/auth failure — all treated identically). * The caller maps this to { error: 'Invalid request' } 400. */ export class CredentialValidationError extends Error { constructor() { super('Credential validation failed'); this.name = 'CredentialValidationError'; } } /** * Validates a Fastmail app password via CalDAV PROPFIND, encrypts it, upserts the * member_credentials row, and fires an asynchronous full-member initial sync. * * @param userId - The target member's user id (admin path: from route; self-service: session user) * @param fastmailEmail - The Fastmail account email * @param appPassword - The plaintext app password (NEVER logged or echoed) * @param providerType - Credential provider type (e.g. 'caldav') * * @throws CredentialValidationError when validation (PROPFIND) fails for ANY reason */ export async function validateEncryptAndStoreCredential( userId: number, fastmailEmail: string, appPassword: string, providerType: string, ): Promise { // Step 1: Validate credential via CalDAV PROPFIND (createFastmailClient + fetchCalendars). // Both calls are wrapped in ONE try/catch. ANY throw from either — bad email, malformed // input, network error, PROPFIND 401/403 from Fastmail — maps to CredentialValidationError. // The password is NEVER logged here or in the catch block (T-10-10). let davCalendars: Awaited> = []; try { const client = await createFastmailClient(fastmailEmail, appPassword); davCalendars = await client.fetchCalendars(); } catch { // T-10-10: do NOT log appPassword, fastmailEmail, or the error details here. // Only a typed signal is thrown — routes map it to the generic 400 response. throw new CredentialValidationError(); } // Step 2: Encrypt the password (AES-256-GCM) BEFORE any DB write (T-10-11). // NEVER log encryptedPassword or appPassword. const encrypted = encryptPassword(appPassword); // Step 3: Upsert member_credentials using the UNIQUE(user_id) constraint (D-05). await db .insert(memberCredentials) .values({ userId, encryptedPassword: encrypted, fastmailEmail, providerType, }) .onDuplicateKeyUpdate({ set: { encryptedPassword: encrypted, fastmailEmail, providerType, }, }); // Step 4: Fire-and-forget initial full-member sync. // After a FRESH credential save there may be no known calendarUrl — run the full per-member // poll (loadClientForUser → fetchCalendars → syncCalendar per davCal) mirroring poller.ts. // This is non-blocking: the caller returns 200 immediately; sync runs in the background. // Pitfall 5: encrypt+upsert BEFORE triggering sync (credential must exist in DB first). const calsToSync = davCalendars; void (async () => { try { const syncClient = await loadClientForUser(userId); const cals = calsToSync.length > 0 ? calsToSync : await syncClient.fetchCalendars(); for (const davCal of cals) { await syncCalendar(syncClient, davCal, userId); } } catch { // Initial sync failure is non-fatal — the poller will catch up on next tick. // T-10-10: do NOT log password or credential details here. } })(); }