credentialSync.ts:
- validateEncryptAndStoreCredential(userId, email, appPassword, providerType) — single
shared validate→encrypt→store→initial-sync path used by BOTH admin and self-service
- createFastmailClient + fetchCalendars wrapped in ONE try/catch: any failure throws
CredentialValidationError (routes map to { error: 'Invalid request' } 400)
- appPassword never logged or echoed (T-10-10)
- encryptPassword (AES-256-GCM) applied before DB write (T-10-11)
- fire-and-forget initial sync via loadClientForUser + syncCalendar (Pitfall 5)
admin.ts:
- adminRouter.use('*', requireAdmin) FIRST (Pitfall 9 / T-10-08)
- GET /members: users LEFT JOIN member_credentials → hasCredential boolean
- POST /credentials: noEchoHook + validateEncryptAndStoreCredential (T-10-09)
- GET /calendars: calendar list (UI-SPEC Surface 5)
- PUT /calendars/:id/shared: exclusive is_shared update (ADMIN-02, D-06)
index.ts:
- app.route('/api/admin', adminRouter) mounted in route block
me.ts:
- POST /credential: member self-service, always currentUserId (Pitfall 6 / T-10-12)
- meCredentialSchema (no userId field), meNoEchoHook, calls shared helper
- All 17 new admin tests pass; 270 total pass; tsc --noEmit clean
109 lines
4.5 KiB
TypeScript
109 lines
4.5 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
// 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<ReturnType<FastmailClient['fetchCalendars']>> = [];
|
|
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.
|
|
}
|
|
})();
|
|
}
|